diff --git a/README.md b/README.md index e66094cb7..0b6239322 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ Ensure you have [Docker](https://docs.docker.com/get-docker/) and [Docker Compos ### 2️⃣ Create docker-compose.yml +> ⚠️ If you intend to run the container as a non-root user, you must manually create all of your `/your/local/path/to/booklore` directories with read and write permissions for your intended user **before first run**. + Create a `docker-compose.yml` file with content: ```yaml diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index 2b8c9e1c0..fe290f70d 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -60,6 +60,9 @@ dependencies { implementation 'com.github.jai-imageio:jai-imageio-jpeg2000:1.4.0' implementation 'io.documentnode:epub4j-core:4.2.2' + // --- UNRAR Support --- + implementation 'com.github.junrar:junrar:7.5.5' + // --- JSON & Web Scraping --- implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.19.2' implementation 'org.jsoup:jsoup:1.21.1' @@ -71,7 +74,6 @@ dependencies { // --- API Documentation --- implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' implementation 'org.apache.commons:commons-compress:1.28.0' - implementation 'com.github.junrar:junrar:7.5.5' implementation 'org.apache.commons:commons-text:1.14.0' // --- Test Dependencies --- diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java new file mode 100644 index 000000000..8d524e7fa --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java @@ -0,0 +1,55 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.UploadResponse; +import com.adityachandel.booklore.model.dto.UrlRequest; +import com.adityachandel.booklore.service.BackgroundUploadService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/background") +@RequiredArgsConstructor +public class BackgroundUploadController { + + private final BackgroundUploadService backgroundUploadService; + private final AuthenticationService authenticationService; + + @PostMapping("/upload") + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { + try { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + UploadResponse response = backgroundUploadService.uploadBackgroundFile(file, authenticatedUser.getId()); + return ResponseEntity.ok(response); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } + } + + @PostMapping("/url") + public ResponseEntity uploadUrl(@RequestBody UrlRequest request) { + try { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + UploadResponse response = backgroundUploadService.uploadBackgroundFromUrl(request.getUrl(), authenticatedUser.getId()); + return ResponseEntity.ok(response); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } + } + + @DeleteMapping + public ResponseEntity resetToDefault() { + try { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + backgroundUploadService.resetToDefault(authenticatedUser.getId()); + return ResponseEntity.ok().build(); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java index 54fa57209..ef0e0096f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java @@ -36,6 +36,7 @@ public class BookController { private final BookService bookService; private final BookRecommendationService bookRecommendationService; + private final BookMetadataService bookMetadataService; @GetMapping public ResponseEntity> getBooks(@RequestParam(required = false, defaultValue = "false") boolean withDescription) { @@ -59,6 +60,12 @@ public class BookController { return ResponseEntity.ok(bookService.getBooksByIds(ids, withDescription)); } + @GetMapping("/{bookId}/cbx/metadata/comicinfo") + public ResponseEntity getComicInfoMetadata(@PathVariable long bookId) { + return ResponseEntity.ok(bookMetadataService.getComicInfoMetadata(bookId)); + } + + @GetMapping("/{bookId}/content") @CheckBookAccess(bookIdParam = "bookId") public ResponseEntity getBookContent(@PathVariable long bookId) throws IOException { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java index 4d32c6455..8c4f25e19 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.controller; +import com.adityachandel.booklore.config.security.service.AuthenticationService; import com.adityachandel.booklore.service.BookService; import com.adityachandel.booklore.service.bookdrop.BookDropService; import com.adityachandel.booklore.service.metadata.BookMetadataService; @@ -73,4 +74,26 @@ public class BookMediaController { .body(file) : ResponseEntity.noContent().build(); } + + @GetMapping("/background") + public ResponseEntity getBackgroundImage() { + try { + Resource file = bookService.getBackgroundImage(); + if (file == null || !file.exists()) { + return ResponseEntity.notFound().build(); + } + + String filename = file.getFilename(); + MediaType mediaType = filename != null && filename.endsWith(".png") + ? MediaType.IMAGE_PNG + : MediaType.IMAGE_JPEG; + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + filename) + .contentType(mediaType) + .body(file); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileUploadController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileUploadController.java index 195427b23..1184e8d7a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileUploadController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileUploadController.java @@ -22,11 +22,12 @@ public class FileUploadController { @PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canUpload()") @PostMapping(value = "/upload", consumes = "multipart/form-data") - public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file, @RequestParam("libraryId") long libraryId, @RequestParam("pathId") long pathId) throws IOException { + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file, @RequestParam("libraryId") long libraryId, @RequestParam("pathId") long pathId) throws IOException { if (file.isEmpty()) { throw new IllegalArgumentException("Uploaded file is missing."); } - return ResponseEntity.ok(fileUploadService.uploadFile(file, libraryId, pathId)); + fileUploadService.uploadFile(file, libraryId, pathId); + return ResponseEntity.noContent().build(); } @PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canUpload()") diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java index 865179576..12ca94157 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java @@ -21,27 +21,63 @@ public class OpdsController { private final OpdsService opdsService; private final BookService bookService; - @GetMapping(value = "/catalog", produces = "application/atom+xml;profile=opds-catalog") + @GetMapping(produces = {"application/opds+json"}) + public ResponseEntity getRootNavigation(HttpServletRequest request) { + // Only OPDS 2 navigation is defined for root + String nav = opdsService.generateOpdsV2Navigation(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds+json;profile=navigation")) + .body(nav); + } + + @GetMapping(value = "/libraries", produces = {"application/opds+json"}) + public ResponseEntity getLibrariesNavigation(HttpServletRequest request) { + String nav = opdsService.generateOpdsV2LibrariesNavigation(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds+json;profile=navigation")) + .body(nav); + } + + + @GetMapping(value = "/shelves", produces = {"application/opds+json"}) + public ResponseEntity getShelvesNavigation(HttpServletRequest request) { + String nav = opdsService.generateOpdsV2ShelvesNavigation(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds+json;profile=navigation")) + .body(nav); + } + + @GetMapping(value = "/catalog", produces = {"application/opds+json", "application/atom+xml;profile=opds-catalog"}) public ResponseEntity getCatalogFeed(HttpServletRequest request) { String feed = opdsService.generateCatalogFeed(request); + MediaType contentType = selectContentType(request); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog")) + .contentType(contentType) .body(feed); } - @GetMapping(value = "/search", produces = "application/atom+xml;profile=opds-catalog") + @GetMapping(value = "/search", produces = {"application/opds+json", "application/atom+xml;profile=opds-catalog"}) public ResponseEntity search(HttpServletRequest request) { String feed = opdsService.generateSearchResults(request, request.getParameter("q")); + MediaType contentType = selectContentType(request); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog")) + .contentType(contentType) .body(feed); } - @GetMapping(value = "/search.opds", produces = "application/atom+xml;profile=opds-catalog") + @GetMapping(value = "/recent", produces = {"application/opds+json"}) + public ResponseEntity recent(HttpServletRequest request) { + String feed = opdsService.generateRecentFeed(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds+json;profile=acquisition")) + .body(feed); + } + + @GetMapping(value = "/search.opds", produces = "application/opensearchdescription+xml") public ResponseEntity searchDescription(HttpServletRequest request) { String feed = opdsService.generateSearchDescription(request); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog")) + .contentType(MediaType.parseMediaType("application/opensearchdescription+xml")) .body(feed); } @@ -59,4 +95,24 @@ public class OpdsController { .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + coverImage.getFilename() + "\"") .body(coverImage); } + + @GetMapping(value = "/publications/{bookId}", produces = "application/opds-publication+json") + public ResponseEntity getPublication(HttpServletRequest request, @PathVariable long bookId) { + String publication = opdsService.generateOpdsV2Publication(request, bookId); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds-publication+json")) + .body(publication); + } + + private MediaType selectContentType(HttpServletRequest request) { + // Force OPDS 2 JSON when using v2-only filters + if (request.getParameter("shelfId") != null || request.getParameter("libraryId") != null) { + return MediaType.parseMediaType("application/opds+json;profile=acquisition"); + } + String accept = request.getHeader("Accept"); + if (accept != null && (accept.contains("application/opds+json") || accept.contains("version=2.0"))) { + return MediaType.parseMediaType("application/opds+json;profile=acquisition"); + } + return MediaType.parseMediaType("application/atom+xml;profile=opds-catalog"); + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UploadResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UploadResponse.java new file mode 100644 index 000000000..3aad3b1e1 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UploadResponse.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UploadResponse { + private String url; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UrlRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UrlRequest.java new file mode 100644 index 000000000..bc3de6209 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UrlRequest.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UrlRequest { + private String url; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java index b04dc2155..c45862740 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java @@ -11,6 +11,8 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class MetadataPersistenceSettings { private boolean saveToOriginalFile; + private boolean convertCbrCb7ToCbz; private boolean backupMetadata; private boolean backupCover; + private boolean moveFilesToLibraryPattern; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java index 2e44fa4a7..32756c6f8 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java @@ -2,6 +2,8 @@ package com.adityachandel.booklore.repository; import com.adityachandel.booklore.model.entity.BookEntity; import jakarta.transaction.Transactional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -20,6 +22,8 @@ public interface BookRepository extends JpaRepository, JpaSpec Optional findByCurrentHash(String currentHash); + Optional findByCurrentHashAndDeletedTrue(String currentHash); + @Query("SELECT b.id FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)") Set findBookIdsByLibraryId(@Param("libraryId") long libraryId); @@ -37,10 +41,18 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadata(); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @Query(value = "SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)") + Page findAllWithMetadata(Pageable pageable); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) @Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByIds(@Param("bookIds") Set bookIds); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)") + List findWithMetadataByIdsWithPagination(@Param("bookIds") Set bookIds, Pageable pageable); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) @Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByLibraryId(@Param("libraryId") Long libraryId); @@ -49,10 +61,19 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection libraryIds); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @Query(value = "SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)") + Page findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection libraryIds, Pageable pageable); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) @Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @Query(value = "SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)", + countQuery = "SELECT COUNT(DISTINCT b.id) FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)") + Page findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId, Pageable pageable); + @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)") List findAllWithMetadataByFileSizeKbIsNull(); @@ -82,6 +103,30 @@ public interface BookRepository extends JpaRepository, JpaSpec """) List searchByMetadata(@Param("text") String text); + @Query(value = """ + SELECT DISTINCT b FROM BookEntity b + LEFT JOIN b.metadata m + LEFT JOIN m.authors a + WHERE (b.deleted IS NULL OR b.deleted = false) AND ( + LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%')) + ) + """, + countQuery = """ + SELECT COUNT(DISTINCT b.id) FROM BookEntity b + LEFT JOIN b.metadata m + LEFT JOIN m.authors a + WHERE (b.deleted IS NULL OR b.deleted = false) AND ( + LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%')) + ) + """) + Page searchByMetadata(@Param("text") String text, Pageable pageable); + @Query(""" SELECT DISTINCT b FROM BookEntity b LEFT JOIN FETCH b.metadata m @@ -98,8 +143,36 @@ public interface BookRepository extends JpaRepository, JpaSpec """) List searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection libraryIds); + @Query(value = """ + SELECT DISTINCT b FROM BookEntity b + LEFT JOIN b.metadata m + LEFT JOIN m.authors a + WHERE (b.deleted IS NULL OR b.deleted = false) + AND b.library.id IN :libraryIds + AND ( + LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%')) + ) + """, + countQuery = """ + SELECT COUNT(DISTINCT b.id) FROM BookEntity b + LEFT JOIN b.metadata m + LEFT JOIN m.authors a + WHERE (b.deleted IS NULL OR b.deleted = false) + AND b.library.id IN :libraryIds + AND ( + LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%')) + ) + """) + Page searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection libraryIds, Pageable pageable); + @Modifying @Transactional @Query("DELETE FROM BookEntity b WHERE b.deletedAt IS NOT NULL AND b.deletedAt < :cutoff") int deleteAllByDeletedAtBefore(Instant cutoff); -} \ No newline at end of file + + +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java index 940332408..2447414bb 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java @@ -32,3 +32,4 @@ public interface BookdropFileRepository extends JpaRepository findAllExcludingIdsFlat(@Param("excludedIds") List excludedIds); } + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/AdditionalFileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/AdditionalFileService.java index 9e30dfa54..2a45ffc19 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/AdditionalFileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/AdditionalFileService.java @@ -1,15 +1,11 @@ package com.adityachandel.booklore.service; -import com.adityachandel.booklore.exception.ApiError; 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.entity.BookEntity; import com.adityachandel.booklore.model.enums.AdditionalFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import com.adityachandel.booklore.util.FileUtils; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; @@ -19,19 +15,11 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.util.HexFormat; import java.util.List; -import java.util.Objects; import java.util.Optional; @Slf4j @@ -41,7 +29,7 @@ public class AdditionalFileService { private final BookAdditionalFileRepository additionalFileRepository; private final AdditionalFileMapper additionalFileMapper; - private final MonitoringProtectionService monitoringProtectionService; + private final MonitoringRegistrationService monitoringRegistrationService; public List getAdditionalFilesByBookId(Long bookId) { List entities = additionalFileRepository.findByBookId(bookId); @@ -61,21 +49,18 @@ public class AdditionalFileService { } BookAdditionalFileEntity file = fileOpt.get(); - - monitoringProtectionService.executeWithProtection(() -> { - try { - // Delete physical file - Files.deleteIfExists(file.getFullFilePath()); - log.info("Deleted additional file: {}", file.getFullFilePath()); - // Delete database record - additionalFileRepository.delete(file); - } catch (IOException e) { - log.warn("Failed to delete physical file: {}", file.getFullFilePath(), e); - // Still delete the database record even if file deletion fails - additionalFileRepository.delete(file); - } - }, "additional file deletion"); + try { + monitoringRegistrationService.unregisterSpecificPath(file.getFullFilePath().getParent()); + + Files.deleteIfExists(file.getFullFilePath()); + log.info("Deleted additional file: {}", file.getFullFilePath()); + + additionalFileRepository.delete(file); + } catch (IOException e) { + log.warn("Failed to delete physical file: {}", file.getFullFilePath(), e); + additionalFileRepository.delete(file); + } } public ResponseEntity downloadAdditionalFile(Long fileId) throws IOException { @@ -98,5 +83,4 @@ public class AdditionalFileService { .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFileName() + "\"") .body(resource); } - } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BackgroundUploadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BackgroundUploadService.java new file mode 100644 index 000000000..1c80f0df5 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BackgroundUploadService.java @@ -0,0 +1,112 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.model.dto.UploadResponse; +import com.adityachandel.booklore.util.FileService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.net.URI; +import java.net.URL; +import java.nio.file.Paths; +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BackgroundUploadService { + + private final FileService fileService; + + private static final String JPEG_MIME_TYPE = "image/jpeg"; + private static final String PNG_MIME_TYPE = "image/png"; + private static final long MAX_FILE_SIZE_BYTES = 5L * 1024 * 1024; // 5MB + + public UploadResponse uploadBackgroundFile(MultipartFile file, Long userId) { + try { + validateBackgroundFile(file); + + String originalFilename = Objects.requireNonNull(file.getOriginalFilename()); + String extension = getFileExtension(originalFilename); + String filename = "1." + extension; + + BufferedImage originalImage = ImageIO.read(file.getInputStream()); + if (originalImage == null) { + throw new IllegalArgumentException("Invalid image file"); + } + + deleteExistingBackgroundFiles(userId); + fileService.saveBackgroundImage(originalImage, filename, userId); + + String fileUrl = fileService.getBackgroundUrl(filename, userId); + return new UploadResponse(fileUrl); + } catch (Exception e) { + log.error("Failed to upload background file: {}", e.getMessage(), e); + throw new RuntimeException("Failed to upload file: " + e.getMessage(), e); + } + } + + public UploadResponse uploadBackgroundFromUrl(String imageUrl, Long userId) { + try { + URL url = new URI(imageUrl).toURL(); + String originalFilename = Paths.get(url.getPath()).getFileName().toString(); + String extension = getFileExtension(originalFilename); + String filename = "1." + extension; + + BufferedImage originalImage = fileService.downloadImageFromUrl(imageUrl); + deleteExistingBackgroundFiles(userId); + + fileService.saveBackgroundImage(originalImage, filename, userId); + + String fileUrl = fileService.getBackgroundUrl(filename, userId); + return new UploadResponse(fileUrl); + } catch (Exception e) { + log.error("Failed to upload background from URL: {}", e.getMessage(), e); + throw new RuntimeException("Invalid or inaccessible URL: " + e.getMessage(), e); + } + } + + public void resetToDefault(Long userId) { + try { + deleteExistingBackgroundFiles(userId); + log.info("Reset background to default successfully for user: {}", userId); + } catch (Exception e) { + log.error("Failed to reset background to default: {}", e.getMessage(), e); + throw new RuntimeException("Failed to reset background: " + e.getMessage(), e); + } + } + + private void deleteExistingBackgroundFiles(Long userId) { + try { + fileService.deleteBackgroundFile("1.jpg", userId); + fileService.deleteBackgroundFile("1.jpeg", userId); + fileService.deleteBackgroundFile("1.png", userId); + } catch (Exception e) { + log.warn("Failed to delete existing background files: {}", e.getMessage()); + } + } + + private void validateBackgroundFile(MultipartFile file) { + if (file.isEmpty()) { + throw new IllegalArgumentException("Background image file is empty"); + } + String contentType = file.getContentType(); + if (!(JPEG_MIME_TYPE.equalsIgnoreCase(contentType) || PNG_MIME_TYPE.equalsIgnoreCase(contentType))) { + throw new IllegalArgumentException("Background image must be JPEG or PNG format"); + } + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new IllegalArgumentException("Background image size must not exceed 5 MB"); + } + } + + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < filename.length() - 1) { + return filename.substring(lastDotIndex + 1).toLowerCase(); + } + return "jpg"; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java index 7ea6c5d6f..58a995f9b 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java @@ -5,8 +5,16 @@ import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.repository.BookRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -31,6 +39,36 @@ public class BookQueryService { .collect(Collectors.toList()); } + public Page getAllBooksPage(boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.findAllWithMetadata(pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + + public Page getRecentBooksPage(boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by("addedOn").descending()); + Page books = bookRepository.findAllWithMetadata(pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + public List getAllBooksByLibraryIds(Set libraryIds, boolean includeDescription) { List books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds); return books.stream() @@ -44,10 +82,45 @@ public class BookQueryService { .collect(Collectors.toList()); } + public Page getAllBooksByLibraryIdsPage(Set libraryIds, boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds, pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + + public Page getRecentBooksByLibraryIdsPage(Set libraryIds, boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by("addedOn").descending()); + Page books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds, pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + public List findAllWithMetadataByIds(Set bookIds) { return bookRepository.findAllWithMetadataByIds(bookIds); } + public List findWithMetadataByIdsWithPagination(Set bookIds, int offset, int limit) { + Pageable pageable = PageRequest.of(offset / limit, limit); + return bookRepository.findWithMetadataByIdsWithPagination(bookIds, pageable); + } + public List getAllFullBookEntities() { return bookRepository.findAllFullBooks(); } @@ -59,6 +132,15 @@ public class BookQueryService { .collect(Collectors.toList()); } + public Page searchBooksByMetadataPage(String text, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.searchByMetadata(text, pageable); + List mapped = books.getContent().stream() + .map(bookMapperV2::toDTO) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + public List searchBooksByMetadataInLibraries(String text, Set libraryIds) { List bookEntities = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds); return bookEntities.stream() @@ -66,7 +148,33 @@ public class BookQueryService { .collect(Collectors.toList()); } + public Page searchBooksByMetadataInLibrariesPage(String text, Set libraryIds, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds, pageable); + List mapped = books.getContent().stream() + .map(bookMapperV2::toDTO) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + + public Page getAllBooksByShelfPage(Long shelfId, boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.findAllWithMetadataByShelfId(shelfId, pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + public void saveAll(List books) { bookRepository.saveAll(books); } + + // Removed OPDS Magic Shelves support } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java index e2beae57a..7213e4225 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java @@ -15,7 +15,7 @@ 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.repository.*; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.FileUtils; import lombok.AllArgsConstructor; @@ -59,7 +59,7 @@ public class BookService { private final BookQueryService bookQueryService; private final UserProgressService userProgressService; private final BookDownloadService bookDownloadService; - private final MonitoringProtectionService monitoringProtectionService; + private final MonitoringRegistrationService monitoringRegistrationService; private void setBookProgress(Book book, UserBookProgressEntity progress) { @@ -468,14 +468,23 @@ public class BookService { if (Files.exists(coverPath)) { return new UrlResource(coverPath.toUri()); } else { - Path defaultCover = Paths.get("static/images/missing-cover.jpg"); - return new UrlResource(defaultCover.toUri()); + return new ClassPathResource("static/images/missing-cover.jpg"); } } catch (MalformedURLException e) { throw new RuntimeException("Failed to load book cover for bookId=" + bookId, e); } } + public Resource getBackgroundImage() { + try { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + return fileService.getBackgroundResource(user.getId()); + } catch (Exception e) { + log.error("Failed to get background image: {}", e.getMessage(), e); + return fileService.getBackgroundResource(null); + } + } + public ResponseEntity downloadBook(Long bookId) { return bookDownloadService.downloadBook(bookId); } @@ -494,35 +503,33 @@ public class BookService { public ResponseEntity deleteBooks(Set ids) { List books = bookQueryService.findAllWithMetadataByIds(ids); List failedFileDeletions = new ArrayList<>(); + for (BookEntity book : books) { + Path fullFilePath = book.getFullFilePath(); + try { + if (Files.exists(fullFilePath)) { + monitoringRegistrationService.unregisterSpecificPath(fullFilePath.getParent()); + Files.delete(fullFilePath); + log.info("Deleted book file: {}", fullFilePath); - return monitoringProtectionService.executeWithProtection(() -> { - for (BookEntity book : books) { - Path fullFilePath = book.getFullFilePath(); - try { - if (Files.exists(fullFilePath)) { - Files.delete(fullFilePath); - log.info("Deleted book file: {}", fullFilePath); - - Set libraryRoots = book.getLibrary().getLibraryPaths().stream() - .map(LibraryPathEntity::getPath) - .map(Paths::get) - .map(Path::normalize) - .collect(Collectors.toSet()); + Set libraryRoots = book.getLibrary().getLibraryPaths().stream() + .map(LibraryPathEntity::getPath) + .map(Paths::get) + .map(Path::normalize) + .collect(Collectors.toSet()); - deleteEmptyParentDirsUpToLibraryFolders(fullFilePath.getParent(), libraryRoots); - } - } catch (IOException e) { - log.warn("Failed to delete book file: {}", fullFilePath, e); - failedFileDeletions.add(book.getId()); + deleteEmptyParentDirsUpToLibraryFolders(fullFilePath.getParent(), libraryRoots); } + } catch (IOException e) { + log.warn("Failed to delete book file: {}", fullFilePath, e); + failedFileDeletions.add(book.getId()); } + } - bookRepository.deleteAll(books); - BookDeletionResponse response = new BookDeletionResponse(ids, failedFileDeletions); - return failedFileDeletions.isEmpty() - ? ResponseEntity.ok(response) - : ResponseEntity.status(HttpStatus.MULTI_STATUS).body(response); - }, "book deletion"); + bookRepository.deleteAll(books); + BookDeletionResponse response = new BookDeletionResponse(ids, failedFileDeletions); + return failedFileDeletions.isEmpty() + ? ResponseEntity.ok(response) + : ResponseEntity.status(HttpStatus.MULTI_STATUS).body(response); } public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set libraryRoots) throws IOException { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java index ef98938a1..c4af60d6f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java @@ -183,8 +183,10 @@ public class SettingPersistenceHelper { public MetadataPersistenceSettings getDefaultMetadataPersistenceSettings() { return MetadataPersistenceSettings.builder() .saveToOriginalFile(false) + .convertCbrCb7ToCbz(false) .backupMetadata(false) .backupCover(false) + .moveFilesToLibraryPattern(false) .build(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java index b10e02f10..b949cad22 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java @@ -4,7 +4,6 @@ import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.BookdropFileMapper; import com.adityachandel.booklore.model.FileProcessResult; -import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.BookdropFile; import com.adityachandel.booklore.model.dto.BookdropFileNotification; @@ -23,17 +22,14 @@ import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.BookdropFileRepository; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.NotificationService; -import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.file.FileMovingHelper; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; import com.adityachandel.booklore.service.metadata.MetadataRefreshService; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; import com.adityachandel.booklore.util.FileUtils; -import com.adityachandel.booklore.util.PathPatternResolver; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.io.FilenameUtils; import org.springframework.core.io.PathResource; import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; @@ -45,6 +41,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.time.Instant; import java.util.Comparator; import java.util.List; @@ -63,7 +60,6 @@ public class BookDropService { private final BookdropFileRepository bookdropFileRepository; private final LibraryRepository libraryRepository; private final BookRepository bookRepository; - private final MonitoringProtectionService monitoringProtectionService; private final BookdropMonitoringService bookdropMonitoringService; private final NotificationService notificationService; private final MetadataRefreshService metadataRefreshService; @@ -72,7 +68,9 @@ public class BookDropService { private final AppProperties appProperties; private final BookdropFileMapper mapper; private final ObjectMapper objectMapper; - AppSettingService appSettingService; + private final FileMovingHelper fileMovingHelper; + + private static final int CHUNK_SIZE = 100; public BookdropFileNotification getFileNotificationSummary() { long pendingCount = bookdropFileRepository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW); @@ -88,140 +86,169 @@ public class BookDropService { } } - public BookdropFinalizeResult finalizeImport(BookdropFinalizeRequest request) { - return monitoringProtectionService.executeWithProtection(() -> { - try { - bookdropMonitoringService.pauseMonitoring(); - - BookdropFinalizeResult results = BookdropFinalizeResult.builder() - .processedAt(Instant.now()) - .build(); - Long defaultLibraryId = request.getDefaultLibraryId(); - Long defaultPathId = request.getDefaultPathId(); - - Map metadataById = Optional.ofNullable(request.getFiles()) - .orElse(List.of()) - .stream() - .collect(Collectors.toMap(BookdropFinalizeRequest.BookdropFinalizeFile::getFileId, Function.identity())); - - final int CHUNK_SIZE = 100; - AtomicInteger failedCount = new AtomicInteger(); - AtomicInteger totalFilesProcessed = new AtomicInteger(); - - log.info("Starting finalizeImport: selectAll={}, provided file count={}, defaultLibraryId={}, defaultPathId={}", - request.getSelectAll(), metadataById.size(), defaultLibraryId, defaultPathId); - - if (Boolean.TRUE.equals(request.getSelectAll())) { - List excludedIds = Optional.ofNullable(request.getExcludedIds()).orElse(List.of()); - - List allIds = bookdropFileRepository.findAllExcludingIdsFlat(excludedIds); - log.info("SelectAll: Total files to finalize (after exclusions): {}, Excluded IDs: {}", allIds.size(), excludedIds); - - for (int i = 0; i < allIds.size(); i += CHUNK_SIZE) { - int end = Math.min(i + CHUNK_SIZE, allIds.size()); - List chunk = allIds.subList(i, end); - - log.info("Processing chunk {}/{} ({} files): IDs={}", (i / CHUNK_SIZE + 1), (int) Math.ceil((double) allIds.size() / CHUNK_SIZE), chunk.size(), chunk); - - List chunkFiles = bookdropFileRepository.findAllById(chunk); - Map fileMap = chunkFiles.stream().collect(Collectors.toMap(BookdropFileEntity::getId, Function.identity())); - - for (Long id : chunk) { - BookdropFileEntity file = fileMap.get(id); - if (file == null) { - log.warn("File ID {} missing in DB during finalizeImport chunk processing", id); - failedCount.incrementAndGet(); - totalFilesProcessed.incrementAndGet(); - continue; - } - processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, results, failedCount); - totalFilesProcessed.incrementAndGet(); - } - } - } else { - List ids = Optional.ofNullable(request.getFiles()) - .orElse(List.of()) - .stream() - .map(BookdropFinalizeRequest.BookdropFinalizeFile::getFileId) - .toList(); - - log.info("Processing {} manually selected files in chunks of {}. File IDs: {}", ids.size(), CHUNK_SIZE, ids); - - for (int i = 0; i < ids.size(); i += CHUNK_SIZE) { - int end = Math.min(i + CHUNK_SIZE, ids.size()); - List chunkIds = ids.subList(i, end); - List chunkFiles = bookdropFileRepository.findAllById(chunkIds); - - log.info("Processing chunk {} of {} ({} files): IDs={}", (i / CHUNK_SIZE + 1), (int) Math.ceil((double) ids.size() / CHUNK_SIZE), chunkFiles.size(), chunkIds); - - Map fileMap = chunkFiles.stream() - .collect(Collectors.toMap(BookdropFileEntity::getId, Function.identity())); - - for (Long id : chunkIds) { - BookdropFileEntity file = fileMap.get(id); - if (file == null) { - log.error("File ID {} not found in DB during finalizeImport chunk processing", id); - failedCount.incrementAndGet(); - totalFilesProcessed.incrementAndGet(); - continue; - } - processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, results, failedCount); - totalFilesProcessed.incrementAndGet(); - } - } - } - - results.setTotalFiles(totalFilesProcessed.get()); - results.setFailed(failedCount.get()); - results.setSuccessfullyImported(totalFilesProcessed.get() - failedCount.get()); - - log.info("Finalization complete. Success: {}, Failed: {}, Total processed: {}", - results.getSuccessfullyImported(), - results.getFailed(), - results.getTotalFiles()); - - return results; - - } finally { - bookdropMonitoringService.resumeMonitoring(); - } - }, "bookdrop finalize import"); + public Resource getBookdropCover(long bookdropId) { + String coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropId + ".jpg").toString(); + File coverFile = new File(coverPath); + if (coverFile.exists() && coverFile.isFile()) { + return new PathResource(coverFile.toPath()); + } else { + return null; + } } - private void processFile( - BookdropFileEntity fileEntity, - BookdropFinalizeRequest.BookdropFinalizeFile fileReq, - Long defaultLibraryId, - Long defaultPathId, - BookdropFinalizeResult results, - AtomicInteger failedCount - ) { + public BookdropFinalizeResult finalizeImport(BookdropFinalizeRequest request) { try { - Long libraryId; - Long pathId; - BookMetadata metadata; + bookdropMonitoringService.pauseMonitoring(); + return processFinalizationRequest(request); + } finally { + bookdropMonitoringService.resumeMonitoring(); + log.info("Bookdrop monitoring resumed"); + } + } - if (fileReq != null) { - libraryId = fileReq.getLibraryId() != null ? fileReq.getLibraryId() : defaultLibraryId; - pathId = fileReq.getPathId() != null ? fileReq.getPathId() : defaultPathId; - metadata = fileReq.getMetadata(); - log.debug("Processing fileId={}, fileName={} with provided metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId); - } else { - if (defaultLibraryId == null || defaultPathId == null) { - log.warn("Missing default metadata for fileId={}", fileEntity.getId()); - throw ApiError.GENERIC_BAD_REQUEST.createException("Missing metadata and defaults for fileId=" + fileEntity.getId()); - } + public void discardSelectedFiles(boolean selectAll, List excludedIds, List selectedIds) { + bookdropMonitoringService.pauseMonitoring(); + Path bookdropPath = Path.of(appProperties.getBookdropFolder()); - metadata = fileEntity.getFetchedMetadata() != null - ? objectMapper.readValue(fileEntity.getFetchedMetadata(), BookMetadata.class) - : objectMapper.readValue(fileEntity.getOriginalMetadata(), BookMetadata.class); + AtomicInteger deletedFiles = new AtomicInteger(); + AtomicInteger deletedDirs = new AtomicInteger(); + AtomicInteger deletedCovers = new AtomicInteger(); - libraryId = defaultLibraryId; - pathId = defaultPathId; - log.debug("Processing fileId={}, fileName={} with default metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId); + try { + if (!Files.exists(bookdropPath)) { + log.info("Bookdrop folder does not exist: {}", bookdropPath); + return; } - BookdropFileResult result = moveFile(libraryId, pathId, metadata, fileEntity); + List filesToDelete = getFilesToDelete(selectAll, excludedIds, selectedIds); + deleteFilesAndCovers(filesToDelete, deletedFiles, deletedCovers); + deleteEmptyDirectories(bookdropPath, deletedDirs); + + bookdropFileRepository.deleteAllById(filesToDelete.stream().map(BookdropFileEntity::getId).toList()); + log.info("Deleted {} bookdrop DB entries", filesToDelete.size()); + + bookdropNotificationService.sendBookdropFileSummaryNotification(); + log.info("Bookdrop cleanup summary: deleted {} files, {} folders, {} DB entries, {} covers", + deletedFiles.get(), deletedDirs.get(), filesToDelete.size(), deletedCovers.get()); + + } finally { + bookdropMonitoringService.resumeMonitoring(); + log.info("Bookdrop monitoring resumed after cleanup (library monitoring unaffected)"); + } + } + + private BookdropFinalizeResult processFinalizationRequest(BookdropFinalizeRequest request) { + BookdropFinalizeResult results = BookdropFinalizeResult.builder() + .processedAt(Instant.now()) + .build(); + + Long defaultLibraryId = request.getDefaultLibraryId(); + Long defaultPathId = request.getDefaultPathId(); + Map metadataById = getMetadataMap(request); + + AtomicInteger failedCount = new AtomicInteger(); + AtomicInteger totalFilesProcessed = new AtomicInteger(); + + log.info("Starting finalizeImport: selectAll={}, provided file count={}, defaultLibraryId={}, defaultPathId={}", request.getSelectAll(), metadataById.size(), defaultLibraryId, defaultPathId); + + if (Boolean.TRUE.equals(request.getSelectAll())) { + processAllFiles(request, metadataById, defaultLibraryId, defaultPathId, results, failedCount, totalFilesProcessed); + } else { + processSelectedFiles(request, metadataById, defaultLibraryId, defaultPathId, results, failedCount, totalFilesProcessed); + } + + updateFinalResults(results, totalFilesProcessed, failedCount); + return results; + } + + private Map getMetadataMap(BookdropFinalizeRequest request) { + return Optional.ofNullable(request.getFiles()) + .orElse(List.of()) + .stream() + .collect(Collectors.toMap(BookdropFinalizeRequest.BookdropFinalizeFile::getFileId, Function.identity())); + } + + private void processAllFiles(BookdropFinalizeRequest request, + Map metadataById, + Long defaultLibraryId, + Long defaultPathId, + BookdropFinalizeResult results, + AtomicInteger failedCount, + AtomicInteger totalFilesProcessed) { + List excludedIds = Optional.ofNullable(request.getExcludedIds()).orElse(List.of()); + List allIds = bookdropFileRepository.findAllExcludingIdsFlat(excludedIds); + log.info("SelectAll: Total files to finalize (after exclusions): {}, Excluded IDs: {}", allIds.size(), excludedIds); + + processFileChunks(allIds, metadataById, defaultLibraryId, defaultPathId, results, failedCount, totalFilesProcessed); + } + + private void processSelectedFiles(BookdropFinalizeRequest request, + Map metadataById, + Long defaultLibraryId, + Long defaultPathId, + BookdropFinalizeResult results, + AtomicInteger failedCount, + AtomicInteger totalFilesProcessed) { + List ids = Optional.ofNullable(request.getFiles()) + .orElse(List.of()) + .stream() + .map(BookdropFinalizeRequest.BookdropFinalizeFile::getFileId) + .toList(); + + log.info("Processing {} manually selected files in chunks of {}. File IDs: {}", ids.size(), CHUNK_SIZE, ids); + processFileChunks(ids, metadataById, defaultLibraryId, defaultPathId, results, failedCount, totalFilesProcessed); + } + + private void processFileChunks(List ids, + Map metadataById, + Long defaultLibraryId, + Long defaultPathId, + BookdropFinalizeResult results, + AtomicInteger failedCount, + AtomicInteger totalFilesProcessed) { + for (int i = 0; i < ids.size(); i += CHUNK_SIZE) { + int end = Math.min(i + CHUNK_SIZE, ids.size()); + List chunk = ids.subList(i, end); + + log.info("Processing chunk {}/{} ({} files): IDs={}", (i / CHUNK_SIZE + 1), (int) Math.ceil((double) ids.size() / CHUNK_SIZE), chunk.size(), chunk); + + List chunkFiles = bookdropFileRepository.findAllById(chunk); + Map fileMap = chunkFiles.stream().collect(Collectors.toMap(BookdropFileEntity::getId, Function.identity())); + + for (Long id : chunk) { + BookdropFileEntity file = fileMap.get(id); + if (file == null) { + log.warn("File ID {} missing in DB during finalizeImport chunk processing", id); + failedCount.incrementAndGet(); + totalFilesProcessed.incrementAndGet(); + continue; + } + processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, results, failedCount); + totalFilesProcessed.incrementAndGet(); + } + } + } + + private void updateFinalResults(BookdropFinalizeResult results, AtomicInteger totalFilesProcessed, AtomicInteger failedCount) { + results.setTotalFiles(totalFilesProcessed.get()); + results.setFailed(failedCount.get()); + results.setSuccessfullyImported(totalFilesProcessed.get() - failedCount.get()); + + log.info("Finalization complete. Success: {}, Failed: {}, Total processed: {}", + results.getSuccessfullyImported(), + results.getFailed(), + results.getTotalFiles()); + } + + private void processFile(BookdropFileEntity fileEntity, + BookdropFinalizeRequest.BookdropFinalizeFile fileReq, + Long defaultLibraryId, + Long defaultPathId, + BookdropFinalizeResult results, + AtomicInteger failedCount) { + try { + FileProcessingContext context = prepareFileProcessingContext(fileEntity, fileReq, defaultLibraryId, defaultPathId); + BookdropFileResult result = moveFile(context.libraryId, context.pathId, context.metadata, fileEntity); results.getResults().add(result); if (!result.isSuccess()) { @@ -239,7 +266,38 @@ public class BookDropService { } } - private BookdropFileResult moveFile(long libraryId, long pathId, BookMetadata metadata, BookdropFileEntity bookdropFile) throws Exception { + private FileProcessingContext prepareFileProcessingContext(BookdropFileEntity fileEntity, + BookdropFinalizeRequest.BookdropFinalizeFile fileReq, + Long defaultLibraryId, + Long defaultPathId) throws Exception { + Long libraryId; + Long pathId; + BookMetadata metadata; + + if (fileReq != null) { + libraryId = fileReq.getLibraryId() != null ? fileReq.getLibraryId() : defaultLibraryId; + pathId = fileReq.getPathId() != null ? fileReq.getPathId() : defaultPathId; + metadata = fileReq.getMetadata(); + log.debug("Processing fileId={}, fileName={} with provided metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId); + } else { + if (defaultLibraryId == null || defaultPathId == null) { + log.warn("Missing default metadata for fileId={}", fileEntity.getId()); + throw ApiError.GENERIC_BAD_REQUEST.createException("Missing metadata and defaults for fileId=" + fileEntity.getId()); + } + + metadata = fileEntity.getFetchedMetadata() != null + ? objectMapper.readValue(fileEntity.getFetchedMetadata(), BookMetadata.class) + : objectMapper.readValue(fileEntity.getOriginalMetadata(), BookMetadata.class); + + libraryId = defaultLibraryId; + pathId = defaultPathId; + log.debug("Processing fileId={}, fileName={} with default metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId); + } + + return new FileProcessingContext(libraryId, pathId, metadata); + } + + private BookdropFileResult moveFile(long libraryId, long pathId, BookMetadata metadata, BookdropFileEntity bookdropFile) { LibraryEntity library = libraryRepository.findById(libraryId) .orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); @@ -248,22 +306,12 @@ public class BookDropService { .findFirst() .orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryId)); - String filePattern = library.getFileNamingPattern(); - if (filePattern == null || filePattern.isBlank()) { - filePattern = appSettingService.getAppSettings().getUploadPattern(); - } - - if (filePattern.endsWith("/") || filePattern.endsWith("\\")) { - filePattern += "{currentFilename}"; - } - - String relativePath = PathPatternResolver.resolvePattern(metadata, filePattern, FilenameUtils.getName(bookdropFile.getFilePath())); + String filePattern = fileMovingHelper.getFileNamingPattern(library); Path source = Path.of(bookdropFile.getFilePath()); - Path target = Paths.get(path.getPath(), relativePath); + Path target = fileMovingHelper.generateNewFilePath(path.getPath(), metadata, filePattern, bookdropFile.getFilePath()); File targetFile = target.toFile(); - log.debug("Preparing to move file id={}, name={}, source={}, target={}, library={}, path={}", - bookdropFile.getId(), bookdropFile.getFileName(), source, target, library.getName(), path.getPath()); + log.debug("Preparing to move file id={}, name={}, source={}, target={}, library={}, path={}", bookdropFile.getId(), bookdropFile.getFileName(), source, target, library.getName(), path.getPath()); if (!Files.exists(source)) { bookdropFileRepository.deleteById(bookdropFile.getId()); @@ -277,64 +325,93 @@ public class BookDropService { return failureResult(targetFile.getName(), "File already exists in the library '" + library.getName() + "'"); } - return monitoringProtectionService.executeWithProtection(() -> { - try { - Files.createDirectories(target.getParent()); - Files.move(source, target); - - log.info("Moved file id={}, name={} from '{}' to '{}'", bookdropFile.getId(), bookdropFile.getFileName(), source, target); - - FileProcessResult fileProcessResult = processFile(targetFile.getName(), library, path, targetFile, - BookFileExtension.fromFileName(bookdropFile.getFileName()) - .orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")) - .getType()); - - BookEntity bookEntity = bookRepository.findById(fileProcessResult.getBook().getId()) - .orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("Book ID missing after import")); - - notificationService.sendMessage(Topic.BOOK_ADD, fileProcessResult.getStatus()); - metadataRefreshService.updateBookMetadata(bookEntity, metadata, metadata.getThumbnailUrl() != null, false); - bookdropFileRepository.deleteById(bookdropFile.getId()); - bookdropNotificationService.sendBookdropFileSummaryNotification(); - - File cachedCover = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFile.getId() + ".jpg").toFile(); - if (cachedCover.exists()) { - boolean deleted = cachedCover.delete(); - log.debug("Deleted cached cover image for bookdropId={}: {}", bookdropFile.getId(), deleted); - } - - log.info("File import completed: id={}, name={}, library={}, path={}", bookdropFile.getId(), targetFile.getName(), library.getName(), path.getPath()); - - return BookdropFileResult.builder() - .fileName(targetFile.getName()) - .message("File successfully imported into the '" + library.getName() + "' library from the Bookdrop folder") - .success(true) - .build(); - - } catch (Exception e) { - log.error("Failed to move file id={}, name={} from '{}' to '{}': {}", bookdropFile.getId(), bookdropFile.getFileName(), source, target, e.getMessage(), e); - try { - if (Files.exists(target)) { - Files.deleteIfExists(target); - log.info("Cleaned up partially created target file: {}", target); - } - } catch (Exception cleanupException) { - log.warn("Failed to cleanup target file after move error: {}", target, cleanupException); - } - return failureResult(bookdropFile.getFileName(), "Failed to move file: " + e.getMessage()); - } - }, "bookdrop file move"); + return performFileMove(bookdropFile, source, target, library, path, metadata); } - private BookdropFileResult failureResult(String fileName, String message) { + private BookdropFileResult performFileMove(BookdropFileEntity bookdropFile, Path source, Path target, + LibraryEntity library, LibraryPathEntity path, BookMetadata metadata) { + Path tempPath = null; + try { + tempPath = Files.createTempFile("bookdrop-finalize-", bookdropFile.getFileName()); + Files.copy(source, tempPath, StandardCopyOption.REPLACE_EXISTING); + + Files.createDirectories(target.getParent()); + Files.move(tempPath, target, StandardCopyOption.REPLACE_EXISTING); + Files.deleteIfExists(source); + + log.info("Moved file id={}, name={} from '{}' to '{}'", bookdropFile.getId(), bookdropFile.getFileName(), source, target); + + return processMovedFile(bookdropFile, target.toFile(), library, path, metadata); + + } catch (Exception e) { + log.error("Failed to move file id={}, name={} from '{}' to '{}': {}", bookdropFile.getId(), bookdropFile.getFileName(), source, target, e.getMessage(), e); + cleanupFailedMove(target); + return failureResult(bookdropFile.getFileName(), "Failed to move file: " + e.getMessage()); + } finally { + cleanupTempFile(tempPath); + } + } + + private BookdropFileResult processMovedFile(BookdropFileEntity bookdropFile, + File targetFile, + LibraryEntity library, + LibraryPathEntity path, + BookMetadata metadata) throws Exception { + FileProcessResult fileProcessResult = processFileInLibrary(targetFile.getName(), library, path, targetFile, + BookFileExtension.fromFileName(bookdropFile.getFileName()) + .orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")) + .getType()); + + BookEntity bookEntity = bookRepository.findById(fileProcessResult.getBook().getId()) + .orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("Book ID missing after import")); + + notificationService.sendMessage(Topic.BOOK_ADD, fileProcessResult.getBook()); + metadataRefreshService.updateBookMetadata(bookEntity, metadata, metadata.getThumbnailUrl() != null, false); + + cleanupBookdropData(bookdropFile); + + log.info("File import completed: id={}, name={}, library={}, path={}", bookdropFile.getId(), targetFile.getName(), library.getName(), path.getPath()); + return BookdropFileResult.builder() - .fileName(fileName) - .message(message) - .success(false) + .fileName(targetFile.getName()) + .message("File successfully imported into the '" + library.getName() + "' library from the Bookdrop folder") + .success(true) .build(); } - private FileProcessResult processFile(String fileName, LibraryEntity library, LibraryPathEntity path, File file, BookFileType type) { + private void cleanupBookdropData(BookdropFileEntity bookdropFile) { + bookdropFileRepository.deleteById(bookdropFile.getId()); + bookdropNotificationService.sendBookdropFileSummaryNotification(); + + File cachedCover = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFile.getId() + ".jpg").toFile(); + if (cachedCover.exists()) { + boolean deleted = cachedCover.delete(); + log.debug("Deleted cached cover image for bookdropId={}: {}", bookdropFile.getId(), deleted); + } + } + + private void cleanupFailedMove(Path target) { + try { + if (Files.exists(target)) { + Files.deleteIfExists(target); + log.info("Cleaned up partially created target file: {}", target); + } + } catch (Exception cleanupException) { + log.warn("Failed to cleanup target file after move error: {}", target, cleanupException); + } + } + + private void cleanupTempFile(Path tempPath) { + if (tempPath != null) { + try { + Files.deleteIfExists(tempPath); + } catch (Exception e) { + log.warn("Failed to cleanup temp file: {}", tempPath, e); + } + } + } + + private FileProcessResult processFileInLibrary(String fileName, LibraryEntity library, LibraryPathEntity path, File file, BookFileType type) { LibraryFile libraryFile = LibraryFile.builder() .libraryEntity(library) .libraryPathEntity(path) @@ -347,87 +424,67 @@ public class BookDropService { return processor.processFile(libraryFile); } - public void discardSelectedFiles(boolean selectAll, List excludedIds, List selectedIds) { - bookdropMonitoringService.pauseMonitoring(); - Path bookdropPath = Path.of(appProperties.getBookdropFolder()); - - AtomicInteger deletedFiles = new AtomicInteger(); - AtomicInteger deletedDirs = new AtomicInteger(); - AtomicInteger deletedCovers = new AtomicInteger(); - - try { - if (!Files.exists(bookdropPath)) { - log.info("Bookdrop folder does not exist: {}", bookdropPath); - return; - } - - List filesToDelete; - if (selectAll) { - filesToDelete = bookdropFileRepository.findAll().stream() - .filter(f -> excludedIds == null || !excludedIds.contains(f.getId())) - .toList(); - log.info("Discarding all files except excluded IDs: {}", excludedIds); - } else { - filesToDelete = bookdropFileRepository.findAllById(selectedIds == null ? List.of() : selectedIds); - log.info("Discarding selected files: {}", selectedIds); - } - - for (BookdropFileEntity entity : filesToDelete) { - try { - Path filePath = Path.of(entity.getFilePath()); - if (Files.exists(filePath) && Files.isRegularFile(filePath) && Files.deleteIfExists(filePath)) { - deletedFiles.incrementAndGet(); - log.debug("Deleted file from disk: id={}, path={}", entity.getId(), filePath); - } - Path coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", entity.getId() + ".jpg"); - if (Files.exists(coverPath) && Files.deleteIfExists(coverPath)) { - deletedCovers.incrementAndGet(); - log.debug("Deleted cover image: id={}, path={}", entity.getId(), coverPath); - } - } catch (IOException e) { - log.warn("Failed to delete file or cover for bookdropId={}: {}", entity.getId(), e.getMessage()); - } - } - - bookdropFileRepository.deleteAllById(filesToDelete.stream().map(BookdropFileEntity::getId).toList()); - log.info("Deleted {} bookdrop DB entries", filesToDelete.size()); - - try (Stream paths = Files.walk(bookdropPath)) { - paths.sorted(Comparator.reverseOrder()) - .filter(p -> !p.equals(bookdropPath) && Files.isDirectory(p)) - .forEach(p -> { - try (Stream subPaths = Files.list(p)) { - if (subPaths.findAny().isEmpty()) { - Files.deleteIfExists(p); - deletedDirs.incrementAndGet(); - log.debug("Deleted empty directory: {}", p); - } - } catch (IOException e) { - log.warn("Failed to delete folder: {}", p, e); - } - }); - } catch (IOException e) { - log.warn("Failed to scan bookdrop folder for empty directories", e); - } - - bookdropNotificationService.sendBookdropFileSummaryNotification(); - log.info("Bookdrop cleanup summary: deleted {} files, {} folders, {} DB entries, {} covers", - deletedFiles.get(), deletedDirs.get(), filesToDelete.size(), deletedCovers.get()); - - } finally { - bookdropMonitoringService.resumeMonitoring(); - log.info("Bookdrop monitoring resumed after cleanup"); - } - } - - public Resource getBookdropCover(long bookdropId) { - String coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropId + ".jpg").toString(); - File coverFile = new File(coverPath); - if (coverFile.exists() && coverFile.isFile()) { - return new PathResource(coverFile.toPath()); + private List getFilesToDelete(boolean selectAll, List excludedIds, List selectedIds) { + if (selectAll) { + List filesToDelete = bookdropFileRepository.findAll().stream() + .filter(f -> excludedIds == null || !excludedIds.contains(f.getId())) + .toList(); + log.info("Discarding all files except excluded IDs: {}", excludedIds); + return filesToDelete; } else { - return null; + List filesToDelete = bookdropFileRepository.findAllById(selectedIds == null ? List.of() : selectedIds); + log.info("Discarding selected files: {}", selectedIds); + return filesToDelete; } } -} + private void deleteFilesAndCovers(List filesToDelete, AtomicInteger deletedFiles, AtomicInteger deletedCovers) { + for (BookdropFileEntity entity : filesToDelete) { + try { + Path filePath = Path.of(entity.getFilePath()); + if (Files.exists(filePath) && Files.isRegularFile(filePath) && Files.deleteIfExists(filePath)) { + deletedFiles.incrementAndGet(); + log.debug("Deleted file from disk: id={}, path={}", entity.getId(), filePath); + } + Path coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", entity.getId() + ".jpg"); + if (Files.exists(coverPath) && Files.deleteIfExists(coverPath)) { + deletedCovers.incrementAndGet(); + log.debug("Deleted cover image: id={}, path={}", entity.getId(), coverPath); + } + } catch (IOException e) { + log.warn("Failed to delete file or cover for bookdropId={}: {}", entity.getId(), e.getMessage()); + } + } + } + + private void deleteEmptyDirectories(Path bookdropPath, AtomicInteger deletedDirs) { + try (Stream paths = Files.walk(bookdropPath)) { + paths.sorted(Comparator.reverseOrder()) + .filter(p -> !p.equals(bookdropPath) && Files.isDirectory(p)) + .forEach(p -> { + try (Stream subPaths = Files.list(p)) { + if (subPaths.findAny().isEmpty()) { + Files.deleteIfExists(p); + deletedDirs.incrementAndGet(); + log.debug("Deleted empty directory: {}", p); + } + } catch (IOException e) { + log.warn("Failed to delete folder: {}", p, e); + } + }); + } catch (IOException e) { + log.warn("Failed to scan bookdrop folder for empty directories", e); + } + } + + private BookdropFileResult failureResult(String fileName, String message) { + return BookdropFileResult.builder() + .fileName(fileName) + .message(message) + .success(false) + .build(); + } + + private record FileProcessingContext(Long libraryId, Long pathId, BookMetadata metadata) { + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java index 52fd2c6cf..667203a24 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java @@ -93,8 +93,7 @@ public class BookdropMonitoringService { try { watchKey = bookdrop.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY); + StandardWatchEventKinds.ENTRY_DELETE); paused = false; log.info("Bookdrop monitoring resumed."); } catch (IOException e) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java index d3cd3350b..80fa8e616 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java @@ -3,172 +3,86 @@ package com.adityachandel.booklore.service.file; import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.request.FileMoveRequest; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.LibraryEntity; -import com.adityachandel.booklore.model.entity.LibraryPathEntity; -import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.websocket.Topic; -import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.service.BookQueryService; import com.adityachandel.booklore.service.NotificationService; -import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.adityachandel.booklore.service.library.LibraryService; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; import com.adityachandel.booklore.util.PathPatternResolver; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.io.File; -import java.io.IOException; -import java.nio.file.*; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; @Slf4j @Service @AllArgsConstructor public class FileMoveService { + private static final int BATCH_SIZE = 100; + private final BookQueryService bookQueryService; private final BookRepository bookRepository; - private final BookAdditionalFileRepository bookAdditionalFileRepository; private final BookMapper bookMapper; private final NotificationService notificationService; - private final LibraryService libraryService; - private final MonitoringProtectionService monitoringProtectionService; - private final AppSettingService appSettingService; + private final UnifiedFileMoveService unifiedFileMoveService; public void moveFiles(FileMoveRequest request) { Set bookIds = request.getBookIds(); - List books = bookQueryService.findAllWithMetadataByIds(bookIds); - String defaultPattern = appSettingService.getAppSettings().getUploadPattern(); + log.info("Moving {} books in batches of {}", bookIds.size(), BATCH_SIZE); - log.info("Starting file move for {} books", books.size()); + List allUpdatedBooks = new ArrayList<>(); + int totalProcessed = 0; + int offset = 0; - Set libraryIds = new HashSet<>(); + while (offset < bookIds.size()) { + log.info("Processing batch {}/{}", (offset / BATCH_SIZE) + 1, (bookIds.size() + BATCH_SIZE - 1) / BATCH_SIZE); + + List batchBooks = bookQueryService.findWithMetadataByIdsWithPagination(bookIds, offset, BATCH_SIZE); + + if (batchBooks.isEmpty()) { + log.info("No more books at offset {}", offset); + break; + } + + List batchUpdatedBooks = processBookChunk(batchBooks); + allUpdatedBooks.addAll(batchUpdatedBooks); + + totalProcessed += batchBooks.size(); + offset += BATCH_SIZE; + + log.info("Processed {}/{} books", totalProcessed, bookIds.size()); + } + + log.info("Move completed: {} books processed, {} updated", totalProcessed, allUpdatedBooks.size()); + sendUpdateNotifications(allUpdatedBooks); + } + + public String generatePathFromPattern(BookEntity book, String pattern) { + return PathPatternResolver.resolvePattern(book, pattern); + } + + private List processBookChunk(List books) { List updatedBooks = new ArrayList<>(); - monitoringProtectionService.executeWithProtection(() -> { - for (BookEntity book : books) { - processBookMove(book, defaultPattern, updatedBooks, libraryIds); + unifiedFileMoveService.moveBatchBookFiles(books, new UnifiedFileMoveService.BatchMoveCallback() { + @Override + public void onBookMoved(BookEntity book) { + bookRepository.save(book); + updatedBooks.add(bookMapper.toBook(book)); } - log.info("Completed file move for {} books.", books.size()); - sendUpdateNotifications(updatedBooks); - }, "file move operations"); - - // Trigger library rescan immediately after file operations complete - if (!libraryIds.isEmpty()) { - rescanLibraries(libraryIds); - } - } - - - private void processBookMove(BookEntity book, String defaultPattern, List updatedBooks, Set libraryIds) { - if (book.getMetadata() == null) return; - - String pattern = getFileNamingPattern(book, defaultPattern); - if (pattern == null) return; - - if (!hasRequiredPathComponents(book)) return; - - Path oldFilePath = book.getFullFilePath(); - if (!Files.exists(oldFilePath)) { - log.warn("File does not exist for book id {}: {}", book.getId(), oldFilePath); - return; - } - - log.info("Processing book id {}: '{}'", book.getId(), book.getMetadata().getTitle()); - - Path newFilePath = generateNewFilePath(book, pattern); - if (oldFilePath.equals(newFilePath)) { - log.info("Source and destination paths are identical for book id {}. Skipping.", book.getId()); - return; - } - - try { - moveFileAndUpdateBook(book, oldFilePath, newFilePath, updatedBooks, libraryIds); - - // Move additional files if present - if (book.getAdditionalFiles() != null && !book.getAdditionalFiles().isEmpty()) { - moveAdditionalFiles(book, pattern); + @Override + public void onBookMoveFailed(BookEntity book, Exception error) { + log.error("Move failed for book {}: {}", book.getId(), error.getMessage(), error); + throw new RuntimeException("File move failed for book id " + book.getId(), error); } - } catch (IOException e) { - log.error("Failed to move file for book id {}: {}", book.getId(), e.getMessage(), e); - } - } + }); - private String getFileNamingPattern(BookEntity book, String defaultPattern) { - if (book.getLibraryPath() == null || book.getLibraryPath().getLibrary() == null) { - log.error("Book id {} has no library associated. Skipping.", book.getId()); - return null; - } - LibraryEntity library = book.getLibraryPath().getLibrary(); - String pattern = library.getFileNamingPattern(); - if (pattern == null || pattern.trim().isEmpty()) { - pattern = defaultPattern; - log.info("Using default pattern for library {} as no custom pattern is set", library.getName()); - } - if (pattern == null || pattern.trim().isEmpty()) { - log.error("No file naming pattern available for book id {}. Skipping.", book.getId()); - return null; - } - - return pattern; - } - - private boolean hasRequiredPathComponents(BookEntity book) { - if (book.getLibraryPath() == null || book.getLibraryPath().getPath() == null || - book.getFileSubPath() == null || book.getFileName() == null) { - log.error("Missing required path components for book id {}. Skipping.", book.getId()); - return false; - } - return true; - } - - private Path generateNewFilePath(BookEntity book, String pattern) { - String newRelativePathStr = generatePathFromPattern(book, pattern); - if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) { - newRelativePathStr = newRelativePathStr.substring(1); - } - - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); - return libraryRoot.resolve(newRelativePathStr).normalize(); - } - - private void moveFileAndUpdateBook(BookEntity book, Path oldFilePath, Path newFilePath, - List updatedBooks, Set libraryIds) throws IOException { - if (newFilePath.getParent() != null) { - Files.createDirectories(newFilePath.getParent()); - } - - log.info("Moving file from {} to {}", oldFilePath, newFilePath); - Files.move(oldFilePath, newFilePath, StandardCopyOption.REPLACE_EXISTING); - - updateBookPaths(book, newFilePath); - bookRepository.save(book); - updatedBooks.add(bookMapper.toBook(book)); - - log.info("Updated book id {} with new path", book.getId()); - - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); - deleteEmptyParentDirsUpToLibraryFolders(oldFilePath.getParent(), Set.of(libraryRoot)); - - if (book.getLibraryPath().getLibrary().getId() != null) { - libraryIds.add(book.getLibraryPath().getLibrary().getId()); - } - } - - private void updateBookPaths(BookEntity book, Path newFilePath) { - String newFileName = newFilePath.getFileName().toString(); - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); - Path newRelativeSubPath = libraryRoot.relativize(newFilePath.getParent()); - String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); - - book.setFileSubPath(newFileSubPath); - book.setFileName(newFileName); + return updatedBooks; } private void sendUpdateNotifications(List updatedBooks) { @@ -176,169 +90,4 @@ public class FileMoveService { notificationService.sendMessage(Topic.BOOK_METADATA_BATCH_UPDATE, updatedBooks); } } - - private void rescanLibraries(Set libraryIds) { - for (Long libraryId : libraryIds) { - try { - libraryService.rescanLibrary(libraryId); - log.info("Rescanned library id {} after file move", libraryId); - } catch (Exception e) { - log.error("Failed to rescan library id {}: {}", libraryId, e.getMessage(), e); - } - } - } - - - - public String generatePathFromPattern(BookEntity book, String pattern) { - return PathPatternResolver.resolvePattern(book, pattern); - } - - private void moveAdditionalFiles(BookEntity book, String pattern) throws IOException { - Map fileNameCounter = new HashMap<>(); - - for (BookAdditionalFileEntity additionalFile : book.getAdditionalFiles()) { - Path oldAdditionalFilePath = additionalFile.getFullFilePath(); - if (!Files.exists(oldAdditionalFilePath)) { - log.warn("Additional file does not exist for book id {}: {}", book.getId(), oldAdditionalFilePath); - continue; - } - - String newRelativePathStr = PathPatternResolver.resolvePattern(book.getMetadata(), pattern, additionalFile.getFileName()); - if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) { - newRelativePathStr = newRelativePathStr.substring(1); - } - - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); - Path newAdditionalFilePath = libraryRoot.resolve(newRelativePathStr).normalize(); - - // Check for filename uniqueness and add index if necessary - newAdditionalFilePath = ensureUniqueFilePath(newAdditionalFilePath, fileNameCounter); - - if (oldAdditionalFilePath.equals(newAdditionalFilePath)) { - log.debug("Source and destination paths are identical for additional file id {}. Skipping.", additionalFile.getId()); - continue; - } - - // Create parent directories if needed - if (newAdditionalFilePath.getParent() != null) { - Files.createDirectories(newAdditionalFilePath.getParent()); - } - - log.info("Moving additional file from {} to {}", oldAdditionalFilePath, newAdditionalFilePath); - Files.move(oldAdditionalFilePath, newAdditionalFilePath, StandardCopyOption.REPLACE_EXISTING); - - // Update additional file paths - updateAdditionalFilePaths(additionalFile, newAdditionalFilePath, libraryRoot); - bookAdditionalFileRepository.save(additionalFile); - - log.info("Updated additional file id {} with new path", additionalFile.getId()); - } - } - - private Path ensureUniqueFilePath(Path filePath, Map fileNameCounter) { - String fileName = filePath.getFileName().toString(); - String baseName = fileName; - String extension = ""; - - int lastDot = fileName.lastIndexOf("."); - if (lastDot >= 0 && lastDot < fileName.length() - 1) { - baseName = fileName.substring(0, lastDot); - extension = fileName.substring(lastDot); - } - - String fileKey = filePath.toString().toLowerCase(); - Integer count = fileNameCounter.get(fileKey); - - if (count == null) { - fileNameCounter.put(fileKey, 1); - return filePath; - } else { - // File name already exists, add index - count++; - fileNameCounter.put(fileKey, count); - String newFileName = baseName + "_" + count + extension; - return filePath.getParent().resolve(newFileName); - } - } - - private void updateAdditionalFilePaths(BookAdditionalFileEntity additionalFile, Path newFilePath, Path libraryRoot) { - String newFileName = newFilePath.getFileName().toString(); - Path newRelativeSubPath = libraryRoot.relativize(newFilePath.getParent()); - String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); - - additionalFile.setFileSubPath(newFileSubPath); - additionalFile.setFileName(newFileName); - } - - public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set libraryRoots) throws IOException { - Set ignoredFilenames = Set.of(".DS_Store", "Thumbs.db"); - currentDir = currentDir.toAbsolutePath().normalize(); - - Set normalizedRoots = new HashSet<>(); - for (Path root : libraryRoots) { - normalizedRoots.add(root.toAbsolutePath().normalize()); - } - - while (currentDir != null) { - if (isLibraryRoot(currentDir, normalizedRoots)) { - log.debug("Reached library root: {}. Stopping cleanup.", currentDir); - break; - } - - File[] files = currentDir.toFile().listFiles(); - if (files == null) { - log.warn("Cannot read directory: {}. Stopping cleanup.", currentDir); - break; - } - - if (hasOnlyIgnoredFiles(files, ignoredFilenames)) { - deleteIgnoredFilesAndDirectory(files, currentDir); - currentDir = currentDir.getParent(); - } else { - log.debug("Directory {} contains important files. Stopping cleanup.", currentDir); - break; - } - } - } - - private boolean isLibraryRoot(Path currentDir, Set normalizedRoots) { - for (Path root : normalizedRoots) { - try { - if (Files.isSameFile(root, currentDir)) { - return true; - } - } catch (IOException e) { - log.warn("Failed to compare paths: {} and {}", root, currentDir); - } - } - return false; - } - - private boolean hasOnlyIgnoredFiles(File[] files, Set ignoredFilenames) { - for (File file : files) { - if (!ignoredFilenames.contains(file.getName())) { - return false; - } - } - return true; - } - - private void deleteIgnoredFilesAndDirectory(File[] files, Path currentDir) { - for (File file : files) { - try { - Files.delete(file.toPath()); - log.info("Deleted ignored file: {}", file.getAbsolutePath()); - } catch (IOException e) { - log.warn("Failed to delete ignored file: {}", file.getAbsolutePath()); - } - } - - try { - Files.delete(currentDir); - log.info("Deleted empty directory: {}", currentDir); - } catch (IOException e) { - log.warn("Failed to delete directory: {}", currentDir, e); - } - } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java new file mode 100644 index 000000000..91084d202 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java @@ -0,0 +1,303 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.util.PathPatternResolver; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FilenameUtils; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@AllArgsConstructor +public class FileMovingHelper { + + private final BookAdditionalFileRepository bookAdditionalFileRepository; + private final AppSettingService appSettingService; + + /** + * Generates the new file path based on the library's file naming pattern + */ + public Path generateNewFilePath(BookEntity book, String pattern) { + String newRelativePathStr = PathPatternResolver.resolvePattern(book, pattern); + if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) { + newRelativePathStr = newRelativePathStr.substring(1); + } + + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + return libraryRoot.resolve(newRelativePathStr).normalize(); + } + + /** + * Generates the new file path based on metadata and file naming pattern + */ + public Path generateNewFilePath(String libraryRootPath, BookMetadata metadata, String pattern, String fileName) { + String relativePath = PathPatternResolver.resolvePattern(metadata, pattern, FilenameUtils.getName(fileName)); + return Paths.get(libraryRootPath, relativePath); + } + + /** + * Gets the file naming pattern for a library, falling back to default if not set, + * and finally to {currentFilename} if no patterns are available + */ + public String getFileNamingPattern(LibraryEntity library) { + String pattern = library.getFileNamingPattern(); + if (pattern == null || pattern.trim().isEmpty()) { + try { + pattern = appSettingService.getAppSettings().getUploadPattern(); + log.debug("Using default pattern for library {} as no custom pattern is set", library.getName()); + } catch (Exception e) { + log.warn("Failed to get default upload pattern for library {}: {}", library.getName(), e.getMessage()); + } + } + if (pattern == null || pattern.trim().isEmpty()) { + pattern = "{currentFilename}"; + log.info("No file naming pattern available for library {}. Using fallback pattern: {currentFilename}", library.getName()); + } + + // Ensure pattern ends with filename placeholder if it ends with separator + if (pattern.endsWith("/") || pattern.endsWith("\\")) { + pattern += "{currentFilename}"; + } + + return pattern; + } + + /** + * Checks if a book has all required path components for file operations + */ + public boolean hasRequiredPathComponents(BookEntity book) { + if (book.getLibraryPath() == null || book.getLibraryPath().getPath() == null || + book.getFileSubPath() == null || book.getFileName() == null) { + log.error("Missing required path components for book id {}. Skipping.", book.getId()); + return false; + } + return true; + } + + /** + * Moves a book file if the current path differs from the expected pattern + */ + public boolean moveBookFileIfNeeded(BookEntity book, String pattern) throws IOException { + Path oldFilePath = book.getFullFilePath(); + Path newFilePath = generateNewFilePath(book, pattern); + + if (oldFilePath.equals(newFilePath)) { + log.debug("Source and destination paths are identical for book id {}. Skipping.", book.getId()); + return false; + } + + moveBookFileAndUpdatePaths(book, oldFilePath, newFilePath); + return true; + } + + /** + * Moves a file from source to target path, creating directories as needed + */ + public void moveFile(Path source, Path target) throws IOException { + if (target.getParent() != null) { + Files.createDirectories(target.getParent()); + } + + log.info("Moving file from {} to {}", source, target); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + /** + * Updates book entity paths after a file move + */ + public void updateBookPaths(BookEntity book, Path newFilePath) { + String newFileName = newFilePath.getFileName().toString(); + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + Path newRelativeSubPath = libraryRoot.relativize(newFilePath.getParent()); + String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); + + book.setFileSubPath(newFileSubPath); + book.setFileName(newFileName); + } + + /** + * Moves additional files for a book based on the file naming pattern + */ + public void moveAdditionalFiles(BookEntity book, String pattern) throws IOException { + Map fileNameCounter = new HashMap<>(); + + for (BookAdditionalFileEntity additionalFile : book.getAdditionalFiles()) { + moveAdditionalFile(book, additionalFile, pattern, fileNameCounter); + } + } + + /** + * Deletes empty parent directories up to library roots + */ + public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set libraryRoots) throws IOException { + Set ignoredFilenames = Set.of(".DS_Store", "Thumbs.db"); + currentDir = currentDir.toAbsolutePath().normalize(); + + Set normalizedRoots = new HashSet<>(); + for (Path root : libraryRoots) { + normalizedRoots.add(root.toAbsolutePath().normalize()); + } + + while (currentDir != null) { + if (isLibraryRoot(currentDir, normalizedRoots)) { + log.debug("Reached library root: {}. Stopping cleanup.", currentDir); + break; + } + + File[] files = currentDir.toFile().listFiles(); + if (files == null) { + log.warn("Cannot read directory: {}. Stopping cleanup.", currentDir); + break; + } + + if (hasOnlyIgnoredFiles(files, ignoredFilenames)) { + deleteIgnoredFilesAndDirectory(files, currentDir); + currentDir = currentDir.getParent(); + } else { + log.debug("Directory {} contains important files. Stopping cleanup.", currentDir); + break; + } + } + } + + private void moveBookFileAndUpdatePaths(BookEntity book, Path oldFilePath, Path newFilePath) throws IOException { + moveFile(oldFilePath, newFilePath); + updateBookPaths(book, newFilePath); + + // Clean up empty directories + try { + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + deleteEmptyParentDirsUpToLibraryFolders(oldFilePath.getParent(), Set.of(libraryRoot)); + } catch (IOException e) { + log.warn("Failed to clean up empty directories after moving book ID {}: {}", book.getId(), e.getMessage()); + } + } + + private void moveAdditionalFile(BookEntity book, BookAdditionalFileEntity additionalFile, String pattern, Map fileNameCounter) throws IOException { + Path oldAdditionalFilePath = additionalFile.getFullFilePath(); + if (!Files.exists(oldAdditionalFilePath)) { + log.warn("Additional file does not exist for book id {}: {}", book.getId(), oldAdditionalFilePath); + return; + } + + Path newAdditionalFilePath = generateAdditionalFilePath(book, additionalFile, pattern); + newAdditionalFilePath = ensureUniqueFilePath(newAdditionalFilePath, fileNameCounter); + + if (oldAdditionalFilePath.equals(newAdditionalFilePath)) { + log.debug("Source and destination paths are identical for additional file id {}. Skipping.", additionalFile.getId()); + return; + } + + moveFile(oldAdditionalFilePath, newAdditionalFilePath); + + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + updateAdditionalFilePaths(additionalFile, newAdditionalFilePath, libraryRoot); + bookAdditionalFileRepository.save(additionalFile); + + log.info("Updated additional file id {} with new path", additionalFile.getId()); + } + + private Path generateAdditionalFilePath(BookEntity book, BookAdditionalFileEntity additionalFile, String pattern) { + String newRelativePathStr = PathPatternResolver.resolvePattern(book.getMetadata(), pattern, additionalFile.getFileName()); + // Fall back to the filename when resolver returns null/empty to avoid NPEs in callers/tests + if (newRelativePathStr == null || newRelativePathStr.trim().isEmpty()) { + newRelativePathStr = additionalFile.getFileName() != null ? additionalFile.getFileName() : ""; + } + if (!newRelativePathStr.isEmpty() && (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\"))) { + newRelativePathStr = newRelativePathStr.substring(1); + } + + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + return libraryRoot.resolve(newRelativePathStr).normalize(); + } + + private Path ensureUniqueFilePath(Path filePath, Map fileNameCounter) { + String fileName = filePath.getFileName().toString(); + String baseName = fileName; + String extension = ""; + + int lastDot = fileName.lastIndexOf("."); + if (lastDot >= 0 && lastDot < fileName.length() - 1) { + baseName = fileName.substring(0, lastDot); + extension = fileName.substring(lastDot); + } + + String fileKey = filePath.toString().toLowerCase(); + Integer count = fileNameCounter.get(fileKey); + + if (count == null) { + fileNameCounter.put(fileKey, 1); + return filePath; + } else { + count++; + fileNameCounter.put(fileKey, count); + String newFileName = baseName + "_" + count + extension; + return filePath.getParent().resolve(newFileName); + } + } + + private void updateAdditionalFilePaths(BookAdditionalFileEntity additionalFile, Path newFilePath, Path libraryRoot) { + String newFileName = newFilePath.getFileName().toString(); + Path newRelativeSubPath = libraryRoot.relativize(newFilePath.getParent()); + String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); + + additionalFile.setFileSubPath(newFileSubPath); + additionalFile.setFileName(newFileName); + } + + private boolean isLibraryRoot(Path currentDir, Set normalizedRoots) { + for (Path root : normalizedRoots) { + try { + if (Files.isSameFile(root, currentDir)) { + return true; + } + } catch (IOException e) { + log.warn("Failed to compare paths: {} and {}", root, currentDir); + } + } + return false; + } + + private boolean hasOnlyIgnoredFiles(File[] files, Set ignoredFilenames) { + for (File file : files) { + if (!ignoredFilenames.contains(file.getName())) { + return false; + } + } + return true; + } + + private void deleteIgnoredFilesAndDirectory(File[] files, Path currentDir) { + for (File file : files) { + try { + Files.delete(file.toPath()); + log.info("Deleted ignored file: {}", file.getAbsolutePath()); + } catch (IOException e) { + log.warn("Failed to delete ignored file: {}", file.getAbsolutePath()); + } + } + try { + Files.delete(currentDir); + log.info("Deleted empty directory: {}", currentDir); + } catch (IOException e) { + log.warn("Failed to delete directory: {}", currentDir, e); + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/MonitoredFileOperationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/MonitoredFileOperationService.java new file mode 100644 index 000000000..9815411ef --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/MonitoredFileOperationService.java @@ -0,0 +1,147 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Service responsible for executing file operations while temporarily disabling monitoring + * on specific paths to prevent event loops and race conditions during file system operations. + * + * This service provides targeted path protection by unregistering only the directories + * involved in the operation rather than pausing all monitoring, ensuring minimal + * disruption to the monitoring system. + */ +@Slf4j +@Component +@AllArgsConstructor +public class MonitoredFileOperationService { + + private final MonitoringRegistrationService monitoringRegistrationService; + + /** + * Executes a file operation with targeted path protection to prevent monitoring conflicts. + * + * This method temporarily unregisters monitoring for only the specific directories involved + * in the operation (source and target) rather than pausing all monitoring. This approach + * minimizes the impact on the monitoring system while preventing event loops that could + * occur when the monitoring service detects changes from our own file operations. + * + * The process follows these steps: + * 1. Identify source and target directories + * 2. Temporarily unregister monitoring for these specific paths + * 3. Execute the file operation + * 4. Wait for filesystem operations to settle + * 5. Re-register paths and scan for new directory structures + * + * @param sourcePath the source file path for the operation + * @param targetPath the target file path for the operation + * @param libraryId the library ID used for re-registration of monitoring + * @param operation the file operation to execute (supplied as a lambda) + * @param the return type of the operation + */ + public void executeWithMonitoringSuspended(Path sourcePath, Path targetPath, Long libraryId, Supplier operation) { + // Extract parent directories since we monitor directories, not individual files + Path sourceDir = sourcePath.getParent(); + Path targetDir = targetPath.getParent(); + + // Track which paths we unregister so we can restore them later + Set unregisteredPaths = new HashSet<>(); + + try { + // Unregister source directory to prevent detection of file removal events + if (monitoringRegistrationService.isPathMonitored(sourceDir)) { + monitoringRegistrationService.unregisterSpecificPath(sourceDir); + unregisteredPaths.add(sourceDir); + log.debug("Temporarily unregistered source directory to prevent monitoring conflicts: {}", sourceDir); + } + + // Unregister target directory if it's different from source and already exists + // This prevents detection of file creation events during the operation + if (!sourceDir.equals(targetDir) && Files.exists(targetDir) && monitoringRegistrationService.isPathMonitored(targetDir)) { + monitoringRegistrationService.unregisterSpecificPath(targetDir); + unregisteredPaths.add(targetDir); + log.info("Temporarily unregistered target directory to prevent monitoring conflicts: {}", targetDir); + } + + log.debug("Protected {} directory paths from monitoring during file operation", unregisteredPaths.size()); + + // Execute the actual file operation (move, copy, delete, etc.) + T result = operation.get(); + + // Allow filesystem operations to complete and settle before re-enabling monitoring + // This prevents race conditions where monitoring might restart before the operation is fully complete + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Thread interrupted while waiting for filesystem operations to settle"); + } + + } finally { + // Always restore monitoring, even if the operation failed + reregisterPathsAfterMove(unregisteredPaths, libraryId, targetDir); + } + } + + /** + * Restores monitoring registration for paths after a file operation and discovers new directories. + * + * This method handles the cleanup phase by: + * 1. Re-registering previously monitored paths that still exist + * 2. Registering new directory structures created by the operation + * 3. Ensuring comprehensive monitoring coverage after the operation + * + * @param unregisteredPaths the set of paths that were temporarily unregistered + * @param libraryId the library ID to use for new registrations + * @param targetDir the target directory where new structures might have been created + */ + private void reregisterPathsAfterMove(Set unregisteredPaths, Long libraryId, Path targetDir) { + // Restore monitoring for original paths that still exist after the operation + for (Path path : unregisteredPaths) { + if (Files.exists(path) && Files.isDirectory(path)) { + monitoringRegistrationService.registerSpecificPath(path, libraryId); + log.debug("Restored monitoring for existing path: {}", path); + } else { + log.info("Path no longer exists after operation, skipping re-registration: {}", path); + } + } + + // Register monitoring for new directory structures created at the target location + if (Files.exists(targetDir) && Files.isDirectory(targetDir)) { + // Register the target directory itself if it wasn't previously monitored + if (!monitoringRegistrationService.isPathMonitored(targetDir)) { + monitoringRegistrationService.registerSpecificPath(targetDir, libraryId); + log.debug("Registered new target directory for monitoring: {}", targetDir); + } + + // Discover and register any new subdirectories created during the operation + // This ensures complete monitoring coverage for complex directory structures + try (Stream stream = Files.walk(targetDir)) { + stream.filter(Files::isDirectory) + .filter(Files::exists) + .filter(path -> !path.equals(targetDir)) // Skip the parent directory itself + .forEach(path -> { + if (!monitoringRegistrationService.isPathMonitored(path)) { + monitoringRegistrationService.registerSpecificPath(path, libraryId); + log.info("Registered new subdirectory for monitoring: {}", path); + } + }); + } catch (IOException e) { + log.warn("Failed to scan and register new subdirectories at: {}", targetDir, e); + } + } + + log.debug("Completed restoration of monitoring after file operation"); + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java new file mode 100644 index 000000000..a5b89234b --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java @@ -0,0 +1,184 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +@Slf4j +@Service +@AllArgsConstructor +public class UnifiedFileMoveService { + + private final FileMovingHelper fileMovingHelper; + private final MonitoredFileOperationService monitoredFileOperationService; + private final MonitoringRegistrationService monitoringRegistrationService; + + /** + * Moves a single book file to match the library's file naming pattern. + * Used for metadata updates where one file needs to be moved. + */ + public void moveSingleBookFile(BookEntity bookEntity) { + if (bookEntity.getLibraryPath() == null || bookEntity.getLibraryPath().getLibrary() == null) { + log.debug("Book ID {} has no library associated. Skipping file move.", bookEntity.getId()); + return; + } + + String pattern = fileMovingHelper.getFileNamingPattern(bookEntity.getLibraryPath().getLibrary()); + + if (!fileMovingHelper.hasRequiredPathComponents(bookEntity)) { + log.debug("Missing required path components for book ID {}. Skipping file move.", bookEntity.getId()); + return; + } + + Path currentFilePath = bookEntity.getFullFilePath(); + if (!Files.exists(currentFilePath)) { + log.warn("File does not exist for book ID {}: {}. Skipping file move.", bookEntity.getId(), currentFilePath); + return; + } + + // Check if current path differs from expected pattern + Path expectedFilePath = fileMovingHelper.generateNewFilePath(bookEntity, pattern); + if (currentFilePath.equals(expectedFilePath)) { + log.debug("File for book ID {} is already in the correct location according to library pattern. No move needed.", bookEntity.getId()); + return; + } + + log.info("File for book ID {} needs to be moved from {} to {} to match library pattern", bookEntity.getId(), currentFilePath, expectedFilePath); + + // For single file moves, use targeted path protection + monitoredFileOperationService.executeWithMonitoringSuspended(currentFilePath, expectedFilePath, bookEntity.getLibraryPath().getLibrary().getId(), () -> { + try { + boolean moved = fileMovingHelper.moveBookFileIfNeeded(bookEntity, pattern); + if (moved) { + log.info("Successfully moved file for book ID {} from {} to {} to match library pattern", bookEntity.getId(), currentFilePath, bookEntity.getFullFilePath()); + } + return moved; + } catch (IOException e) { + log.error("Failed to move file for book ID {}: {}", bookEntity.getId(), e.getMessage(), e); + throw new RuntimeException("File move failed", e); + } + }); + } + + /** + * Moves multiple book files in batches with library-level monitoring protection. + * Used for bulk file operations where many files need to be moved. + */ + public void moveBatchBookFiles(List books, BatchMoveCallback callback) { + if (books.isEmpty()) { + log.debug("No books to move"); + return; + } + + Set libraryIds = new HashSet<>(); + Map> libraryToRootsMap = new HashMap<>(); + + // Collect library information for monitoring protection + for (BookEntity book : books) { + if (book.getMetadata() == null) continue; + if (!fileMovingHelper.hasRequiredPathComponents(book)) continue; + + Path oldFilePath = book.getFullFilePath(); + if (!Files.exists(oldFilePath)) continue; + + Long libraryId = book.getLibraryPath().getLibrary().getId(); + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + + libraryToRootsMap.computeIfAbsent(libraryId, k -> new HashSet<>()).add(libraryRoot); + libraryIds.add(libraryId); + } + + // Unregister libraries for batch operation + unregisterLibrariesBatch(libraryToRootsMap); + + try { + // Process each book + for (BookEntity book : books) { + if (book.getMetadata() == null) continue; + + String pattern = fileMovingHelper.getFileNamingPattern(book.getLibraryPath().getLibrary()); + + if (!fileMovingHelper.hasRequiredPathComponents(book)) continue; + + Path oldFilePath = book.getFullFilePath(); + if (!Files.exists(oldFilePath)) { + log.warn("File not found for book {}: {}", book.getId(), oldFilePath); + continue; + } + + log.debug("Moving book {}: '{}'", book.getId(), book.getMetadata().getTitle()); + + try { + boolean moved = fileMovingHelper.moveBookFileIfNeeded(book, pattern); + if (moved) { + log.debug("Book {} moved successfully", book.getId()); + callback.onBookMoved(book); + } + + // Move additional files if any + if (book.getAdditionalFiles() != null && !book.getAdditionalFiles().isEmpty()) { + fileMovingHelper.moveAdditionalFiles(book, pattern); + } + } catch (IOException e) { + log.error("Move failed for book {}: {}", book.getId(), e.getMessage(), e); + callback.onBookMoveFailed(book, e); + } + } + + // Small delay to let filesystem operations settle + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted during batch move delay"); + } + + } finally { + // Re-register libraries + registerLibrariesBatch(libraryToRootsMap); + } + } + + private void unregisterLibrariesBatch(Map> libraryToRootsMap) { + log.debug("Unregistering {} libraries for batch move", libraryToRootsMap.size()); + + for (Map.Entry> entry : libraryToRootsMap.entrySet()) { + Long libraryId = entry.getKey(); + monitoringRegistrationService.unregisterLibrary(libraryId); + log.debug("Unregistered library {}", libraryId); + } + } + + private void registerLibrariesBatch(Map> libraryToRootsMap) { + log.debug("Re-registering {} libraries after batch move", libraryToRootsMap.size()); + + for (Map.Entry> entry : libraryToRootsMap.entrySet()) { + Long libraryId = entry.getKey(); + Set libraryRoots = entry.getValue(); + + for (Path libraryRoot : libraryRoots) { + if (Files.exists(libraryRoot) && Files.isDirectory(libraryRoot)) { + monitoringRegistrationService.registerLibraryPaths(libraryId, libraryRoot); + log.debug("Re-registered library {} at {}", libraryId, libraryRoot); + } + } + } + } + + /** + * Callback interface for batch move operations + */ + public interface BatchMoveCallback { + void onBookMoved(BookEntity book); + void onBookMoveFailed(BookEntity book, Exception error); + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java index 42139c57d..bac6ce035 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java @@ -104,6 +104,7 @@ public abstract class AbstractFileProcessor implements BookFileProcessor { if (!sameLibraryPath) { entity.setLibraryPath(libraryFile.getLibraryPathEntity()); + entity.setLibrary(libraryFile.getLibraryEntity()); updated = true; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java index a5c21a42f..9812f374f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java @@ -1,13 +1,16 @@ package com.adityachandel.booklore.service.fileprocessor; import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookMetadataRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.service.BookCreatorService; +import com.adityachandel.booklore.service.metadata.extractor.CbxMetadataExtractor; import com.adityachandel.booklore.service.metadata.MetadataMatchService; import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.FileUtils; @@ -28,11 +31,13 @@ import java.util.*; import static com.adityachandel.booklore.util.FileService.truncate; + @Slf4j @Service public class CbxProcessor extends AbstractFileProcessor implements BookFileProcessor { private final BookMetadataRepository bookMetadataRepository; + private final CbxMetadataExtractor cbxMetadataExtractor; public CbxProcessor(BookRepository bookRepository, BookAdditionalFileRepository bookAdditionalFileRepository, @@ -40,9 +45,11 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce BookMapper bookMapper, FileService fileService, BookMetadataRepository bookMetadataRepository, - MetadataMatchService metadataMatchService) { + MetadataMatchService metadataMatchService, + CbxMetadataExtractor cbxMetadataExtractor) { super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService); this.bookMetadataRepository = bookMetadataRepository; + this.cbxMetadataExtractor = cbxMetadataExtractor; } @Override @@ -51,7 +58,8 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce if (generateCover(bookEntity)) { fileService.setBookCoverPath(bookEntity.getMetadata()); } - setMetadata(bookEntity); + + extractAndSetMetadata(bookEntity); return bookEntity; } @@ -167,6 +175,39 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce return Optional.empty(); } + private void extractAndSetMetadata(BookEntity bookEntity) { + try { + BookMetadata extracted = cbxMetadataExtractor.extractMetadata(new File(FileUtils.getBookFullPath(bookEntity))); + if (extracted == null) { + // Fallback to filename-derived title + setMetadata(bookEntity); + return; + } + + BookMetadataEntity metadata = bookEntity.getMetadata(); + metadata.setTitle(truncate(extracted.getTitle(), 1000)); + metadata.setDescription(truncate(extracted.getDescription(), 5000)); + metadata.setPublisher(truncate(extracted.getPublisher(), 1000)); + metadata.setPublishedDate(extracted.getPublishedDate()); + metadata.setSeriesName(truncate(extracted.getSeriesName(), 1000)); + metadata.setSeriesNumber(extracted.getSeriesNumber()); + metadata.setSeriesTotal(extracted.getSeriesTotal()); + metadata.setPageCount(extracted.getPageCount()); + metadata.setLanguage(truncate(extracted.getLanguage(), 1000)); + + if (extracted.getAuthors() != null) { + bookCreatorService.addAuthorsToBook(extracted.getAuthors(), bookEntity); + } + if (extracted.getCategories() != null) { + bookCreatorService.addCategoriesToBook(extracted.getCategories(), bookEntity); + } + } catch (Exception e) { + log.warn("Failed to extract ComicInfo metadata for '{}': {}", bookEntity.getFileName(), e.getMessage()); + // Fallback to filename-derived title + setMetadata(bookEntity); + } + } + private void setMetadata(BookEntity bookEntity) { String baseName = new File(bookEntity.getFileName()).getName(); String title = baseName diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java index e10d742e8..86b838515 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java @@ -11,8 +11,10 @@ import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.request.BulkMetadataUpdateRequest; import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest; import com.adityachandel.booklore.model.dto.request.ToggleAllLockRequest; +import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.enums.Lock; import com.adityachandel.booklore.model.enums.MetadataProvider; import com.adityachandel.booklore.model.websocket.Topic; @@ -27,9 +29,11 @@ import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore; import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory; +import com.adityachandel.booklore.service.metadata.extractor.CbxMetadataExtractor; import com.adityachandel.booklore.service.metadata.parser.BookParser; import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory; 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.Resource; @@ -37,6 +41,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.io.File; import java.io.IOException; import java.lang.reflect.Method; import java.time.Instant; @@ -67,6 +72,7 @@ public class BookMetadataService { private final BookQueryService bookQueryService; private final Map parserMap; private final MetadataBackupRestoreFactory metadataBackupRestoreFactory; + private final CbxMetadataExtractor cbxMetadataExtractor; private final MetadataWriterFactory metadataWriterFactory; private final MetadataClearFlagsMapper metadataClearFlagsMapper; @@ -163,8 +169,10 @@ public class BookMetadataService { private BookMetadata updateCover(Long bookId, BiConsumer writerAction) { BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - bookEntity.getMetadata().setCoverUpdatedOn(Instant.now()); - if (appSettingService.getAppSettings().getMetadataPersistenceSettings().isSaveToOriginalFile()) { + MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); + boolean saveToOriginalFile = settings.isSaveToOriginalFile(); + boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz(); + if (saveToOriginalFile && (bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) { metadataWriterFactory.getWriter(bookEntity.getBookType()) .ifPresent(writer -> writerAction.accept(writer, bookEntity)); } @@ -219,6 +227,16 @@ public class BookMetadataService { log.info("{}Successfully regenerated cover for book ID {} ({})", progress, book.getId(), title); } + 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()); + return null; + } + return cbxMetadataExtractor.extractMetadata(new File(FileUtils.getBookFullPath(bookEntity))); + } + public BookMetadata restoreMetadataFromBackup(Long bookId) throws IOException { BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); metadataBackupRestoreFactory.getService(bookEntity.getBookType()).restoreEmbeddedMetadata(bookEntity); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java index 5355618c1..3b042a77a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java @@ -13,6 +13,7 @@ import com.adityachandel.booklore.repository.AuthorRepository; import com.adityachandel.booklore.repository.CategoryRepository; import com.adityachandel.booklore.service.FileFingerprint; import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.file.UnifiedFileMoveService; import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore; import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory; import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory; @@ -49,6 +50,7 @@ public class BookMetadataUpdater { private final MetadataWriterFactory metadataWriterFactory; private final MetadataBackupRestoreFactory metadataBackupRestoreFactory; private final BookReviewUpdateService bookReviewUpdateService; + private final UnifiedFileMoveService unifiedFileMoveService; @Transactional(propagation = Propagation.REQUIRES_NEW) public void setBookMetadata(BookEntity bookEntity, MetadataUpdateWrapper wrapper, boolean setThumbnail, boolean mergeCategories) { @@ -74,11 +76,12 @@ public class BookMetadataUpdater { MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); boolean writeToFile = settings.isSaveToOriginalFile(); + boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz(); boolean backupEnabled = settings.isBackupMetadata(); boolean backupCover = settings.isBackupCover(); BookFileType bookType = bookEntity.getBookType(); - if (writeToFile && backupEnabled) { + if (writeToFile && backupEnabled && (bookType != BookFileType.CBX || convertCbrCb7ToCbz)) { try { MetadataBackupRestore service = metadataBackupRestoreFactory.getService(bookType); if (service != null) { @@ -104,26 +107,59 @@ public class BookMetadataUpdater { } if ((writeToFile && hasValueChanges) || thumbnailRequiresUpdate) { - metadataWriterFactory.getWriter(bookType).ifPresent(writer -> { - try { - String thumbnailUrl = setThumbnail ? newMetadata.getThumbnailUrl() : null; + if (bookType == BookFileType.CBX && !convertCbrCb7ToCbz) { + log.info("CBX metadata writing disabled for book ID {}", bookId); + } else { + metadataWriterFactory.getWriter(bookType).ifPresent(writer -> { + try { + String thumbnailUrl = setThumbnail ? newMetadata.getThumbnailUrl() : null; - if ((StringUtils.hasText(thumbnailUrl) && isLocalOrPrivateUrl(thumbnailUrl) || Boolean.TRUE.equals(metadata.getCoverLocked()))) { - log.debug("Blocked local/private thumbnail URL: {}", thumbnailUrl); - thumbnailUrl = null; + if ((StringUtils.hasText(thumbnailUrl) && isLocalOrPrivateUrl(thumbnailUrl) || Boolean.TRUE.equals(metadata.getCoverLocked()))) { + log.debug("Blocked local/private thumbnail URL: {}", thumbnailUrl); + thumbnailUrl = null; + } + + File file = new File(bookEntity.getFullFilePath().toUri()); + writer.writeMetadataToFile(file, metadata, thumbnailUrl, false, clearFlags); + + String newHash; + + // Special handling: If original file was .cbr or .cb7 and now .cbz exists, update to .cbz + File resultingFile = file; + if (!file.exists()) { + // Replace last extension .cbr or .cb7 (case-insensitive) with .cbz + String cbzName = file.getName().replaceFirst("(?i)\\.(cbr|cb7)$", ".cbz"); + File cbzFile = new File(file.getParentFile(), cbzName); + if (cbzFile.exists()) { + bookEntity.setFileName(cbzName); + resultingFile = cbzFile; + } + bookEntity.setFileSizeKb(resultingFile.length() / 1024); + log.info("Converted to CBZ: {} -> {}", file.getAbsolutePath(), resultingFile.getAbsolutePath()); + newHash = FileFingerprint.generateHash(resultingFile.toPath()); + } else { + newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath()); + } + + bookEntity.setCurrentHash(newHash); + } catch (Exception e) { + log.warn("Failed to write metadata for book ID {}: {}", bookId, e.getMessage()); } + }); + } + } - File file = new File(bookEntity.getFullFilePath().toUri()); - writer.writeMetadataToFile(file, metadata, thumbnailUrl, false, clearFlags); - String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath()); - bookEntity.setCurrentHash(newHash); - } catch (Exception e) { - log.warn("Failed to write metadata for book ID {}: {}", bookId, e.getMessage()); - } - }); + boolean moveFilesToLibraryPattern = settings.isMoveFilesToLibraryPattern(); + if (moveFilesToLibraryPattern) { + try { + unifiedFileMoveService.moveSingleBookFile(bookEntity); + } catch (Exception e) { + log.warn("Failed to move files for book ID {} after metadata update: {}", bookId, e.getMessage()); + } } } + private void updateBasicFields(BookMetadata m, BookMetadataEntity e, MetadataClearFlags clear) { handleFieldUpdate(e.getTitleLocked(), clear.isTitle(), m.getTitle(), v -> e.setTitle(nullIfBlank(v))); handleFieldUpdate(e.getSubtitleLocked(), clear.isSubtitle(), m.getSubtitle(), v -> e.setSubtitle(nullIfBlank(v))); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java index 2b4eb3aad..f70c257ec 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java @@ -2,6 +2,8 @@ package com.adityachandel.booklore.service.metadata.backuprestore; import com.adityachandel.booklore.model.MetadataClearFlags; import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; +import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.entity.AuthorEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; @@ -103,8 +105,10 @@ public class BookMetadataRestorer { } try { - boolean saveToOriginal = appSettingService.getAppSettings().getMetadataPersistenceSettings().isSaveToOriginalFile(); - if (saveToOriginal) { + MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); + boolean saveToOriginal = settings.isSaveToOriginalFile(); + boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz(); + if (saveToOriginal && (bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) { metadataWriterFactory.getWriter(bookEntity.getBookType()).ifPresent(writer -> { try { File file = new File(bookEntity.getFullFilePath().toUri()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java index 917c36c85..eacc555b9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java @@ -1,58 +1,660 @@ package com.adityachandel.booklore.service.metadata.extractor; import com.adityachandel.booklore.model.dto.BookMetadata; +import com.github.junrar.Archive; +import com.github.junrar.rarfile.FileHeader; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.List; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Collections; +import java.util.stream.Collectors; +import org.apache.commons.compress.archivers.sevenz.SevenZFile; +import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; +import javax.imageio.ImageIO; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FilenameUtils; import org.springframework.stereotype.Component; - -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; @Slf4j @Component public class CbxMetadataExtractor implements FileMetadataExtractor { - @Override - public BookMetadata extractMetadata(File file) { - String baseName = FilenameUtils.getBaseName(file.getName()); - return BookMetadata.builder() - .title(baseName) - .build(); + @Override + public BookMetadata extractMetadata(File file) { + String baseName = FilenameUtils.getBaseName(file.getName()); + String lowerName = file.getName().toLowerCase(); + + // Non-archive (fallback) + if (!lowerName.endsWith(".cbz") && !lowerName.endsWith(".cbr") && !lowerName.endsWith(".cb7")) { + return BookMetadata.builder().title(baseName).build(); } - @Override - public byte[] extractCover(File file) { - return generatePlaceholderCover(250, 350); - } - - private byte[] generatePlaceholderCover(int width, int height) { - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - Graphics2D g = image.createGraphics(); - - g.setColor(Color.LIGHT_GRAY); - g.fillRect(0, 0, width, height); - - g.setColor(Color.DARK_GRAY); - g.setFont(new Font("SansSerif", Font.BOLD, width / 10)); - FontMetrics fm = g.getFontMetrics(); - String text = "Preview Unavailable"; - - int textWidth = fm.stringWidth(text); - int textHeight = fm.getAscent(); - g.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2); - - g.dispose(); - - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - ImageIO.write(image, "jpg", baos); - return baos.toByteArray(); - } catch (IOException e) { - log.warn("Failed to generate placeholder image", e); - return null; + // CBZ path (ZIP) + if (lowerName.endsWith(".cbz")) { + try (ZipFile zipFile = new ZipFile(file)) { + ZipEntry entry = findComicInfoEntry(zipFile); + if (entry == null) { + return BookMetadata.builder().title(baseName).build(); } + try (InputStream is = zipFile.getInputStream(entry)) { + Document document = buildSecureDocument(is); + return mapDocumentToMetadata(document, baseName); + } + } catch (Exception e) { + log.warn("Failed to extract metadata from CBZ", e); + return BookMetadata.builder().title(baseName).build(); + } } + + // CB7 path (7z) + if (lowerName.endsWith(".cb7")) { + try (SevenZFile sevenZ = new SevenZFile(file)) { + SevenZArchiveEntry entry = findSevenZComicInfoEntry(sevenZ); + if (entry == null) { + return BookMetadata.builder().title(baseName).build(); + } + byte[] xmlBytes = readSevenZEntryBytes(sevenZ, entry); + if (xmlBytes == null) { + return BookMetadata.builder().title(baseName).build(); + } + try (InputStream is = new ByteArrayInputStream(xmlBytes)) { + Document document = buildSecureDocument(is); + return mapDocumentToMetadata(document, baseName); + } + } catch (Exception e) { + log.warn("Failed to extract metadata from CB7", e); + return BookMetadata.builder().title(baseName).build(); + } + } + + // CBR path (RAR) + Archive archive = null; + try { + archive = new Archive(file); + FileHeader header = findComicInfoHeader(archive); + if (header == null) { + return BookMetadata.builder().title(baseName).build(); + } + byte[] xmlBytes = readRarEntryBytes(archive, header); + if (xmlBytes == null) { + return BookMetadata.builder().title(baseName).build(); + } + try (InputStream is = new ByteArrayInputStream(xmlBytes)) { + Document document = buildSecureDocument(is); + return mapDocumentToMetadata(document, baseName); + } + } catch (Exception e) { + log.warn("Failed to extract metadata from CBR", e); + return BookMetadata.builder().title(baseName).build(); + } finally { + try { if (archive != null) archive.close(); } catch (Exception ignore) {} + } + } + + private ZipEntry findComicInfoEntry(ZipFile zipFile) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if ("comicinfo.xml".equalsIgnoreCase(name)) { + return entry; + } + } + return null; + } + + private Document buildSecureDocument(InputStream is) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(is); + } + + private BookMetadata mapDocumentToMetadata( + Document document, + String fallbackTitle + ) { + BookMetadata.BookMetadataBuilder builder = BookMetadata.builder(); + + String title = getTextContent(document, "Title"); + builder.title(title == null || title.isBlank() ? fallbackTitle : title); + + builder.description( + coalesce( + getTextContent(document, "Summary"), + getTextContent(document, "Description") + ) + ); + builder.publisher(getTextContent(document, "Publisher")); + builder.seriesName(getTextContent(document, "Series")); + builder.seriesNumber(parseFloat(getTextContent(document, "Number"))); + builder.seriesTotal(parseInteger(getTextContent(document, "Count"))); + builder.publishedDate( + parseDate( + getTextContent(document, "Year"), + getTextContent(document, "Month"), + getTextContent(document, "Day") + ) + ); + builder.pageCount( + parseInteger( + coalesce( + getTextContent(document, "PageCount"), + getTextContent(document, "Pages") + ) + ) + ); + builder.language(getTextContent(document, "LanguageISO")); + + Set authors = new HashSet<>(); + authors.addAll(splitValues(getTextContent(document, "Writer"))); + authors.addAll(splitValues(getTextContent(document, "Penciller"))); + authors.addAll(splitValues(getTextContent(document, "Inker"))); + authors.addAll(splitValues(getTextContent(document, "Colorist"))); + authors.addAll(splitValues(getTextContent(document, "Letterer"))); + authors.addAll(splitValues(getTextContent(document, "CoverArtist"))); + if (!authors.isEmpty()) { + builder.authors(authors); + } + + Set categories = new HashSet<>(); + categories.addAll(splitValues(getTextContent(document, "Genre"))); + categories.addAll(splitValues(getTextContent(document, "Tags"))); + if (!categories.isEmpty()) { + builder.categories(categories); + } + + return builder.build(); + } + + private String getTextContent(Document document, String tag) { + NodeList nodes = document.getElementsByTagName(tag); + if (nodes.getLength() == 0) { + return null; + } + return nodes.item(0).getTextContent().trim(); + } + + private String coalesce(String a, String b) { + return (a != null && !a.isBlank()) + ? a + : (b != null && !b.isBlank() ? b : null); + } + + private Set splitValues(String value) { + if (value == null) { + return new HashSet<>(); + } + return Arrays.stream(value.split("[,;]")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } + + private Integer parseInteger(String value) { + try { + return (value == null || value.isBlank()) + ? null + : Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private Float parseFloat(String value) { + try { + return (value == null || value.isBlank()) + ? null + : Float.parseFloat(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private LocalDate parseDate(String year, String month, String day) { + Integer y = parseInteger(year); + Integer m = parseInteger(month); + Integer d = parseInteger(day); + if (y == null) { + return null; + } + if (m == null) { + m = 1; + } + if (d == null) { + d = 1; + } + try { + return LocalDate.of(y, m, d); + } catch (Exception e) { + return null; + } + } + + public BookMetadata extractFromComicInfoXml(File xmlFile) { + try (InputStream is = new FileInputStream(xmlFile)) { + Document document = buildSecureDocument(is); + String fallbackTitle = xmlFile.getParentFile() != null + ? xmlFile.getParentFile().getName() + : xmlFile.getName(); + return mapDocumentToMetadata(document, fallbackTitle); + } catch (Exception e) { + log.warn("Failed to parse ComicInfo.xml: {}", e.getMessage()); + String fallbackTitle = xmlFile.getParentFile() != null + ? xmlFile.getParentFile().getName() + : xmlFile.getName(); + return BookMetadata.builder().title(fallbackTitle).build(); + } + } + + @Override + public byte[] extractCover(File file) { + String lowerName = file.getName().toLowerCase(); + + // Non-archive fallback + if (!lowerName.endsWith(".cbz") && !lowerName.endsWith(".cbr") && !lowerName.endsWith(".cb7")) { + return generatePlaceholderCover(250, 350); + } + + // CBZ path + if (lowerName.endsWith(".cbz")) { + try (ZipFile zipFile = new ZipFile(file)) { + ZipEntry coverEntry = findFrontCoverEntry(zipFile); + if (coverEntry != null) { + try (InputStream is = zipFile.getInputStream(coverEntry)) { + return is.readAllBytes(); + } + } else { + // Fallback: first image after sorting alphabetically + ZipEntry firstImage = findFirstAlphabeticalImageEntry(zipFile); + if (firstImage != null) { + try (InputStream is2 = zipFile.getInputStream(firstImage)) { + return is2.readAllBytes(); + } + } + } + } catch (Exception e) { + log.warn("Failed to extract cover image from CBZ", e); + return generatePlaceholderCover(250, 350); + } + } + + // CB7 path + if (lowerName.endsWith(".cb7")) { + try (SevenZFile sevenZ = new SevenZFile(file)) { + // Try via ComicInfo.xml first + SevenZArchiveEntry ci = findSevenZComicInfoEntry(sevenZ); + if (ci != null) { + byte[] xmlBytes = readSevenZEntryBytes(sevenZ, ci); + if (xmlBytes != null) { + try (InputStream is = new ByteArrayInputStream(xmlBytes)) { + Document document = buildSecureDocument(is); + String imageName = findFrontCoverImageName(document); + if (imageName != null) { + SevenZArchiveEntry byName = findSevenZEntryByName(sevenZ, imageName); + if (byName != null) { + return readSevenZEntryBytes(sevenZ, byName); + } + try { + int index = Integer.parseInt(imageName); + SevenZArchiveEntry byIndex = findSevenZImageEntryByIndex(sevenZ, index); + if (byIndex != null) { + return readSevenZEntryBytes(sevenZ, byIndex); + } + } catch (NumberFormatException ignore) { + // continue to fallback + } + } + } + } + } + + // Fallback: first image alphabetically + SevenZArchiveEntry first = findFirstAlphabeticalSevenZImageEntry(sevenZ); + if (first != null) { + return readSevenZEntryBytes(sevenZ, first); + } + } catch (Exception e) { + log.warn("Failed to extract cover image from CB7", e); + return generatePlaceholderCover(250, 350); + } + } + + // CBR path + Archive archive = null; + try { + archive = new Archive(file); + + // Try via ComicInfo.xml first + FileHeader comicInfo = findComicInfoHeader(archive); + if (comicInfo != null) { + byte[] xmlBytes = readRarEntryBytes(archive, comicInfo); + if (xmlBytes != null) { + try (InputStream is = new ByteArrayInputStream(xmlBytes)) { + Document document = buildSecureDocument(is); + String imageName = findFrontCoverImageName(document); + if (imageName != null) { + FileHeader byName = findRarHeaderByName(archive, imageName); + if (byName != null) { + return readRarEntryBytes(archive, byName); + } + try { + int index = Integer.parseInt(imageName); + FileHeader byIndex = findRarImageHeaderByIndex(archive, index); + if (byIndex != null) { + return readRarEntryBytes(archive, byIndex); + } + } catch (NumberFormatException ignore) { + // ignore and continue fallback + } + } + } + } + } + + // Fallback: first image in alphabetical order + FileHeader firstImage = findFirstAlphabeticalImageHeader(archive); + if (firstImage != null) { + return readRarEntryBytes(archive, firstImage); + } + } catch (Exception e) { + log.warn("Failed to extract cover image from CBR", e); + return generatePlaceholderCover(250, 350); + } finally { + try { if (archive != null) archive.close(); } catch (Exception ignore) {} + } + + return generatePlaceholderCover(250, 350); + } + + private ZipEntry findFrontCoverEntry(ZipFile zipFile) { + ZipEntry comicInfoEntry = findComicInfoEntry(zipFile); + if (comicInfoEntry != null) { + try (InputStream is = zipFile.getInputStream(comicInfoEntry)) { + Document document = buildSecureDocument(is); + String imageName = findFrontCoverImageName(document); + if (imageName != null) { + ZipEntry byName = zipFile.getEntry(imageName); + if (byName != null) { + return byName; + } + try { + int index = Integer.parseInt(imageName); + ZipEntry byIndex = findImageEntryByIndex(zipFile, index); + if (byIndex != null) { + return byIndex; + } + } catch (NumberFormatException ignore) { + // ignore + } + } + } catch (Exception e) { + log.warn("Failed to parse ComicInfo.xml for cover", e); + } + } + ZipEntry firstImage = findFirstAlphabeticalImageEntry(zipFile); + return firstImage; + } + + private ZipEntry findImageEntryByIndex(ZipFile zipFile, int index) { + Enumeration entries = zipFile.entries(); + int count = 0; + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory() && isImageEntry(entry.getName())) { + if (count == index) { + return entry; + } + count++; + } + } + return null; + } + + private String findFrontCoverImageName(Document document) { + NodeList pages = document.getElementsByTagName("Page"); + for (int i = 0; i < pages.getLength(); i++) { + org.w3c.dom.Node node = pages.item(i); + if (node instanceof org.w3c.dom.Element) { + org.w3c.dom.Element page = (org.w3c.dom.Element) node; + String type = page.getAttribute("Type"); + if (type != null && type.equalsIgnoreCase("FrontCover")) { + String imageFile = page.getAttribute("ImageFile"); + if (imageFile != null && !imageFile.isBlank()) { + return imageFile.trim(); + } + String image = page.getAttribute("Image"); + if (image != null && !image.isBlank()) { + return image.trim(); + } + } + } + } + return null; + } + + private boolean isImageEntry(String name) { + String lower = name.toLowerCase(); + return ( + lower.endsWith(".jpg") || + lower.endsWith(".jpeg") || + lower.endsWith(".png") || + lower.endsWith(".gif") || + lower.endsWith(".bmp") || + lower.endsWith(".webp") + ); + } + + private byte[] generatePlaceholderCover(int width, int height) { + BufferedImage image = new BufferedImage( + width, + height, + BufferedImage.TYPE_INT_RGB + ); + Graphics2D g = image.createGraphics(); + + g.setColor(Color.LIGHT_GRAY); + g.fillRect(0, 0, width, height); + + g.setColor(Color.DARK_GRAY); + g.setFont(new Font("SansSerif", Font.BOLD, width / 10)); + FontMetrics fm = g.getFontMetrics(); + String text = "Preview Unavailable"; + + int textWidth = fm.stringWidth(text); + int textHeight = fm.getAscent(); + g.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2); + + g.dispose(); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(image, "jpg", baos); + return baos.toByteArray(); + } catch (IOException e) { + log.warn("Failed to generate placeholder image", e); + return null; + } + } + + + // ==== RAR (.cbr) helpers ==== + private FileHeader findComicInfoHeader(Archive archive) { + if (archive == null) return null; + for (FileHeader fh : archive.getFileHeaders()) { + String name = fh.getFileName(); + if (name == null) continue; + String base = baseName(name); + if ("comicinfo.xml".equalsIgnoreCase(base)) { + return fh; + } + } + return null; + } + + private FileHeader findRarHeaderByName(Archive archive, String imageName) { + if (archive == null || imageName == null) return null; + for (FileHeader fh : archive.getFileHeaders()) { + String name = fh.getFileName(); + if (name == null) continue; + if (name.equalsIgnoreCase(imageName)) return fh; + // also try base-name match to be lenient + if (baseName(name).equalsIgnoreCase(baseName(imageName))) return fh; + } + return null; + } + + private FileHeader findRarImageHeaderByIndex(Archive archive, int index) { + int count = 0; + for (FileHeader fh : archive.getFileHeaders()) { + if (!fh.isDirectory() && isImageEntry(fh.getFileName())) { + if (count == index) return fh; + count++; + } + } + return null; + } + + private FileHeader findFirstImageHeader(Archive archive) { + for (FileHeader fh : archive.getFileHeaders()) { + if (!fh.isDirectory() && isImageEntry(fh.getFileName())) { + return fh; + } + } + return null; + } + + private byte[] readRarEntryBytes(Archive archive, FileHeader header) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + archive.extractFile(header, baos); + return baos.toByteArray(); + } catch (Exception e) { + log.warn("Failed to read RAR entry bytes for {}", header != null ? header.getFileName() : "", e); + return null; + } + } + + private String baseName(String path) { + if (path == null) return null; + int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return slash >= 0 ? path.substring(slash + 1) : path; + } + + private FileHeader findFirstAlphabeticalImageHeader(Archive archive) { + if (archive == null) return null; + List images = new ArrayList<>(); + for (FileHeader fh : archive.getFileHeaders()) { + if (fh == null || fh.isDirectory()) continue; + String name = fh.getFileName(); + if (name == null) continue; + if (isImageEntry(name)) { + images.add(fh); + } + } + if (images.isEmpty()) return null; + images.sort(Comparator.comparing( + fh -> fh.getFileName() == null ? "" : fh.getFileName(), + String.CASE_INSENSITIVE_ORDER + )); + return images.get(0); + } + + private ZipEntry findFirstAlphabeticalImageEntry(ZipFile zipFile) { + List images = new ArrayList<>(); + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry e = entries.nextElement(); + if (!e.isDirectory() && isImageEntry(e.getName())) { + images.add(e); + } + } + if (images.isEmpty()) return null; + images.sort(Comparator.comparing( + entry -> entry.getName() == null ? "" : entry.getName(), + String.CASE_INSENSITIVE_ORDER + )); + return images.get(0); + } + + // ==== 7z (.cb7) helpers ==== + private SevenZArchiveEntry findSevenZComicInfoEntry(SevenZFile sevenZ) throws IOException { + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (e == null || e.isDirectory()) continue; + String name = e.getName(); + if (name != null && name.equalsIgnoreCase("ComicInfo.xml")) { + return e; + } + } + return null; + } + + private SevenZArchiveEntry findSevenZEntryByName(SevenZFile sevenZ, String imageName) throws IOException { + if (imageName == null) return null; + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (e == null || e.isDirectory()) continue; + String name = e.getName(); + if (name == null) continue; + if (name.equalsIgnoreCase(imageName)) return e; + // also allow base-name match + if (baseName(name).equalsIgnoreCase(baseName(imageName))) return e; + } + return null; + } + + private SevenZArchiveEntry findSevenZImageEntryByIndex(SevenZFile sevenZ, int index) throws IOException { + int count = 0; + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (!e.isDirectory() && isImageEntry(e.getName())) { + if (count == index) return e; + count++; + } + } + return null; + } + + private SevenZArchiveEntry findFirstAlphabeticalSevenZImageEntry(SevenZFile sevenZ) throws IOException { + List images = new ArrayList<>(); + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (!e.isDirectory() && isImageEntry(e.getName())) { + images.add(e); + } + } + if (images.isEmpty()) return null; + images.sort(Comparator.comparing( + entry -> entry.getName() == null ? "" : entry.getName(), + String.CASE_INSENSITIVE_ORDER + )); + return images.get(0); + } + + private byte[] readSevenZEntryBytes(SevenZFile sevenZ, SevenZArchiveEntry entry) throws IOException { + try (InputStream is = sevenZ.getInputStream(entry); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + if (is == null) return null; + is.transferTo(baos); + return baos.toByteArray(); + } + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriter.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriter.java new file mode 100644 index 000000000..9d2dcf4d0 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriter.java @@ -0,0 +1,454 @@ +package com.adityachandel.booklore.service.metadata.writer; + +import com.adityachandel.booklore.model.MetadataClearFlags; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.LocalDate; +import java.util.Enumeration; +import java.util.Locale; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; +import com.github.junrar.Archive; +import com.github.junrar.rarfile.FileHeader; +import org.apache.commons.compress.archivers.sevenz.SevenZFile; +import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; + +@Slf4j +@Component +public class CbxMetadataWriter implements MetadataWriter { + + @Override + public void writeMetadataToFile(File file, BookMetadataEntity metadata, String thumbnailUrl, boolean restoreMode, MetadataClearFlags clearFlags) { + Path backup = null; + boolean writeSucceeded = false; + try { + // Create a backup next to the source file (temp name, safe to delete later) + backup = Files.createTempFile(file.getParentFile().toPath(), "cbx_backup_", ".bak"); + Files.copy(file.toPath(), backup, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception ex) { + log.warn("Unable to create backup for {}: {}", file.getAbsolutePath(), ex.getMessage(), ex); + } + try { + String nameLower = file.getName().toLowerCase(Locale.ROOT); + boolean isCbz = nameLower.endsWith(".cbz"); + boolean isCbr = nameLower.endsWith(".cbr"); + boolean isCb7 = nameLower.endsWith(".cb7"); + + if (!isCbz && !isCbr && !isCb7) { + log.warn("Unsupported file type for CBX writer: {}", file.getName()); + return; + } + + // Build (or load and update) ComicInfo.xml as a Document + Document doc; + if (isCbz) { + try (ZipFile zipFile = new ZipFile(file)) { + ZipEntry existing = findComicInfoEntry(zipFile); + if (existing != null) { + try (InputStream is = zipFile.getInputStream(existing)) { + doc = buildSecureDocument(is); + } + } else { + doc = newEmptyComicInfo(); + } + } + } else if (isCb7) { + try (SevenZFile sevenZ = new SevenZFile(file)) { + SevenZArchiveEntry existing = null; + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (e != null && !e.isDirectory() && isComicInfoName(e.getName())) { + existing = e; break; + } + } + if (existing != null) { + try (InputStream is = sevenZ.getInputStream(existing)) { + doc = buildSecureDocument(is); + } + } else { + doc = newEmptyComicInfo(); + } + } + } else { // CBR + try (Archive archive = new Archive(file)) { + FileHeader existing = findComicInfoHeader(archive); + if (existing != null) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + archive.extractFile(existing, baos); + try (InputStream is = new java.io.ByteArrayInputStream(baos.toByteArray())) { + doc = buildSecureDocument(is); + } + } + } else { + doc = newEmptyComicInfo(); + } + } + } + + // Apply metadata to the Document + Element root = doc.getDocumentElement(); + MetadataCopyHelper helper = new MetadataCopyHelper(metadata); + helper.copyTitle(restoreMode, clearFlags != null && clearFlags.isTitle(), val -> setElement(doc, root, "Title", val)); + helper.copyDescription(restoreMode, clearFlags != null && clearFlags.isDescription(), val -> { + setElement(doc, root, "Summary", val); + removeElement(root, "Description"); + }); + helper.copyPublisher(restoreMode, clearFlags != null && clearFlags.isPublisher(), val -> setElement(doc, root, "Publisher", val)); + helper.copySeriesName(restoreMode, clearFlags != null && clearFlags.isSeriesName(), val -> setElement(doc, root, "Series", val)); + helper.copySeriesNumber(restoreMode, clearFlags != null && clearFlags.isSeriesNumber(), val -> setElement(doc, root, "Number", formatFloat(val))); + helper.copySeriesTotal(restoreMode, clearFlags != null && clearFlags.isSeriesTotal(), val -> setElement(doc, root, "Count", val != null ? val.toString() : null)); + helper.copyPublishedDate(restoreMode, clearFlags != null && clearFlags.isPublishedDate(), date -> setDateElements(doc, root, date)); + helper.copyPageCount(restoreMode, clearFlags != null && clearFlags.isPageCount(), val -> setElement(doc, root, "PageCount", val != null ? val.toString() : null)); + helper.copyLanguage(restoreMode, clearFlags != null && clearFlags.isLanguage(), val -> setElement(doc, root, "LanguageISO", val)); + helper.copyAuthors(restoreMode, clearFlags != null && clearFlags.isAuthors(), set -> { + setElement(doc, root, "Writer", join(set)); + removeElement(root, "Penciller"); + removeElement(root, "Inker"); + removeElement(root, "Colorist"); + removeElement(root, "Letterer"); + removeElement(root, "CoverArtist"); + }); + helper.copyCategories(restoreMode, clearFlags != null && clearFlags.isCategories(), set -> { + setElement(doc, root, "Genre", join(set)); + removeElement(root, "Tags"); + }); + + // Serialize ComicInfo.xml + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + ByteArrayOutputStream xmlBaos = new ByteArrayOutputStream(); + transformer.transform(new DOMSource(doc), new StreamResult(xmlBaos)); + byte[] xmlBytes = xmlBaos.toByteArray(); + + // Repack depending on container type; always write to a temp target then atomic move + if (isCbz) { + Path temp = Files.createTempFile("cbx_edit", ".cbz"); + repackZipReplacingComicInfo(file.toPath(), temp, xmlBytes); + atomicReplace(temp, file.toPath()); + writeSucceeded = true; + return; + } + + if (isCb7) { + // Convert to CBZ with updated ComicInfo.xml + Path tempZip = Files.createTempFile("cbx_edit", ".cbz"); + try (SevenZFile sevenZ = new SevenZFile(file); + ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempZip))) { + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (e.isDirectory()) continue; + String entryName = e.getName(); + if (isComicInfoName(entryName)) continue; // skip old + if (!isSafeEntryName(entryName)) { + log.warn("Skipping unsafe 7z entry name: {}", entryName); + continue; + } + zos.putNextEntry(new ZipEntry(entryName)); + try (InputStream is = sevenZ.getInputStream(e)) { + if (is != null) is.transferTo(zos); + } + zos.closeEntry(); + } + zos.putNextEntry(new ZipEntry("ComicInfo.xml")); + zos.write(xmlBytes); + zos.closeEntry(); + } + Path target = file.toPath().resolveSibling(stripExtension(file.getName()) + ".cbz"); + atomicReplace(tempZip, target); + try { Files.deleteIfExists(file.toPath()); } catch (Exception ignored) {} + writeSucceeded = true; + return; + } + + // CBR path + String rarBin = System.getenv().getOrDefault("BOOKLORE_RAR_BIN", "rar"); + boolean rarAvailable = isRarAvailable(rarBin); + + if (rarAvailable) { + Path tempDir = Files.createTempDirectory("cbx_rar_"); + try { + // Extract entire RAR into a temp directory + try (Archive archive = new Archive(file)) { + for (FileHeader fh : archive.getFileHeaders()) { + String name = fh.getFileName(); + if (name == null || name.isBlank()) continue; + if (!isSafeEntryName(name)) { + log.warn("Skipping unsafe RAR entry name: {}", name); + continue; + } + Path out = tempDir.resolve(name).normalize(); + if (!out.startsWith(tempDir)) { + log.warn("Skipping traversal entry outside tempDir: {}", name); + continue; + } + if (fh.isDirectory()) { + Files.createDirectories(out); + } else { + Files.createDirectories(out.getParent()); + try (OutputStream os = Files.newOutputStream(out, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + archive.extractFile(fh, os); + } + } + } + } + + // Write/replace ComicInfo.xml in extracted tree root + Path comicInfo = tempDir.resolve("ComicInfo.xml"); + Files.write(comicInfo, xmlBytes); + + // Rebuild RAR in-place (replace original file) + Path targetRar = file.toPath().toAbsolutePath().normalize(); + String rarExec = isSafeExecutable(rarBin) ? rarBin : "rar"; // prefer validated path, then PATH lookup + ProcessBuilder pb = new ProcessBuilder(rarExec, "a", "-idq", "-ep1", "-ma5", targetRar.toString(), "."); + pb.directory(tempDir.toFile()); + Process p = pb.start(); + int code = p.waitFor(); + if (code == 0) { + writeSucceeded = true; + return; + } else { + log.warn("RAR creation failed with exit code {}. Falling back to CBZ conversion for {}", code, file.getName()); + } + } finally { + try { // cleanup temp dir + java.nio.file.Files.walk(tempDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(path -> { try { Files.deleteIfExists(path); } catch (Exception ignore) {} }); + } catch (Exception ignore) {} + } + } else { + log.warn("`rar` binary not found. Falling back to CBZ conversion for {}", file.getName()); + } + + // Fallback: convert the CBR to CBZ containing updated ComicInfo.xml + Path tempZip = Files.createTempFile("cbx_edit", ".cbz"); + try (Archive archive = new Archive(file); + ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempZip))) { + for (FileHeader fh : archive.getFileHeaders()) { + if (fh.isDirectory()) continue; + String entryName = fh.getFileName(); + if (isComicInfoName(entryName)) continue; // skip old + if (!isSafeEntryName(entryName)) { + log.warn("Skipping unsafe RAR entry name: {}", entryName); + continue; + } + zos.putNextEntry(new ZipEntry(entryName)); + archive.extractFile(fh, zos); + zos.closeEntry(); + } + zos.putNextEntry(new ZipEntry("ComicInfo.xml")); + zos.write(xmlBytes); + zos.closeEntry(); + } + Path target = file.toPath().resolveSibling(stripExtension(file.getName()) + ".cbz"); + atomicReplace(tempZip, target); + try { Files.deleteIfExists(file.toPath()); } catch (Exception ignored) {} + writeSucceeded = true; + } catch (Exception e) { + // Attempt to restore the original file from backup + try { + if (backup != null) { + Files.copy(backup, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + log.info("Restored original file from backup after failure: {}", file.getAbsolutePath()); + } + } catch (Exception restoreEx) { + log.warn("Failed to restore original file from backup: {} -> {}", backup, file.getAbsolutePath(), restoreEx); + } + log.warn("Failed to write metadata for {}: {}", file.getName(), e.getMessage(), e); + } finally { + if (writeSucceeded && backup != null) { + try { Files.deleteIfExists(backup); } catch (Exception ignore) {} + } + } + } + + // ----------------------- helpers ----------------------- + + private ZipEntry findComicInfoEntry(ZipFile zipFile) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String n = entry.getName(); + if (isComicInfoName(n)) return entry; + } + return null; + } + + private FileHeader findComicInfoHeader(Archive archive) { + for (FileHeader fh : archive.getFileHeaders()) { + String name = fh.getFileName(); + if (name != null && isComicInfoName(name)) return fh; + } + return null; + } + + private Document buildSecureDocument(InputStream is) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(is); + } + + private Document newEmptyComicInfo() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.newDocument(); + doc.appendChild(doc.createElement("ComicInfo")); + return doc; + } + + private void setElement(Document doc, Element root, String tag, String value) { + removeElement(root, tag); + if (value != null && !value.isBlank()) { + Element el = doc.createElement(tag); + el.setTextContent(value); + root.appendChild(el); + } + } + + private void removeElement(Element root, String tag) { + NodeList nodes = root.getElementsByTagName(tag); + for (int i = nodes.getLength() - 1; i >= 0; i--) { + root.removeChild(nodes.item(i)); + } + } + + private void setDateElements(Document doc, Element root, LocalDate date) { + if (date == null) { + removeElement(root, "Year"); + removeElement(root, "Month"); + removeElement(root, "Day"); + return; + } + setElement(doc, root, "Year", Integer.toString(date.getYear())); + setElement(doc, root, "Month", Integer.toString(date.getMonthValue())); + setElement(doc, root, "Day", Integer.toString(date.getDayOfMonth())); + } + + private String join(Set set) { + return (set == null || set.isEmpty()) ? null : String.join(", ", set); + } + + private String formatFloat(Float val) { + if (val == null) return null; + if (val % 1 == 0) return Integer.toString(val.intValue()); + return val.toString(); + } + + private static boolean isComicInfoName(String name) { + if (name == null) return false; + String n = name.replace('\\', '/'); + if (n.endsWith("/")) return false; + String lower = n.toLowerCase(Locale.ROOT); + return lower.equals("comicinfo.xml") || lower.endsWith("/comicinfo.xml"); + } + + private static boolean isSafeEntryName(String name) { + if (name == null || name.isBlank()) return false; + String n = name.replace('\\', '/'); + if (n.startsWith("/")) return false; // absolute + if (n.contains("../")) return false; // traversal + if (n.contains("\0")) return false; // NUL + return true; + } + + private void repackZipReplacingComicInfo(Path sourceZip, Path targetZip, byte[] xmlBytes) throws Exception { + try (ZipFile zipFile = new ZipFile(sourceZip.toFile()); + ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(targetZip))) { + ZipEntry existing = findComicInfoEntry(zipFile); + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String entryName = entry.getName(); + if (existing != null && entryName.equals(existing.getName())) { + continue; // skip old ComicInfo.xml + } + if (!isSafeEntryName(entryName)) { + log.warn("Skipping unsafe ZIP entry name: {}", entryName); + continue; + } + zos.putNextEntry(new ZipEntry(entryName)); + try (InputStream is = zipFile.getInputStream(entry)) { + is.transferTo(zos); + } + zos.closeEntry(); + } + String entryName = (existing != null ? existing.getName() : "ComicInfo.xml"); + zos.putNextEntry(new ZipEntry(entryName)); + zos.write(xmlBytes); + zos.closeEntry(); + } + } + + private static void atomicReplace(Path temp, Path target) throws Exception { + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + // Fallback if filesystem doesn't support ATOMIC_MOVE + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private boolean isRarAvailable(String rarBin) { + try { + String exec = isSafeExecutable(rarBin) ? rarBin : "rar"; + Process check = new ProcessBuilder(exec, "--help").redirectErrorStream(true).start(); + int exitCode = check.waitFor(); + return (exitCode == 0); + } catch (Exception ex) { + log.warn("RAR binary check failed: {}", ex.getMessage()); + return false; + } + } + + /** + * Returns true if the provided executable reference is a simple name or sanitized absolute/relative path. + * No spaces or shell meta chars; passed as argv to ProcessBuilder (no shell). + */ + private boolean isSafeExecutable(String exec) { + if (exec == null || exec.isBlank()) return false; + // allow word chars, dot, slash, backslash, dash and underscore (no spaces or shell metas) + return exec.matches("^[\\w./\\\\-]+$"); + } + + private static String stripExtension(String filename) { + int i = filename.lastIndexOf('.'); + if (i > 0) return filename.substring(0, i); + return filename; + } + + @Override + public BookFileType getSupportedBookType() { + return BookFileType.CBX; + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringProtectionService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringProtectionService.java deleted file mode 100644 index 644f50e27..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringProtectionService.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.adityachandel.booklore.service.monitoring; - -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.function.Supplier; - -/** - * Thread-safe service for managing monitoring protection during file operations. - * - * This service prevents race conditions where file operations are detected as - * "missing files" by the monitoring system, which could lead to data loss. - * - * The service ensures: - * - Thread-safe pause/resume operations - * - Monitoring always resumes even on exceptions - * - Protection against concurrent operations interfering with each other - */ -@Slf4j -@Service -@AllArgsConstructor -public class MonitoringProtectionService { - - private final MonitoringService monitoringService; - - // Synchronization lock to prevent race conditions in pause/resume logic - private static final Object monitoringLock = new Object(); - - /** - * Executes an operation with monitoring protection. - * - * @param operation The operation to execute while monitoring is paused - * @param operationName Name for logging purposes - * @param Return type of the operation - * @return Result of the operation - */ - public T executeWithProtection(Supplier operation, String operationName) { - boolean didPause = pauseMonitoringSafely(); - - try { - log.debug("Executing {} with monitoring protection (paused: {})", operationName, didPause); - return operation.get(); - } finally { - resumeMonitoringSafely(didPause, operationName); - } - } - - /** - * Executes a void operation with monitoring protection. - * - * @param operation The operation to execute while monitoring is paused - * @param operationName Name for logging purposes - */ - public void executeWithProtection(Runnable operation, String operationName) { - executeWithProtection(() -> { - operation.run(); - return null; - }, operationName); - } - - /** - * Thread-safe pause of monitoring service. - * - * @return true if monitoring was paused by this call, false if already paused - */ - private boolean pauseMonitoringSafely() { - synchronized (monitoringLock) { - if (!monitoringService.isPaused()) { - monitoringService.pauseMonitoring(); - log.debug("Monitoring paused for file operations"); - return true; - } - log.debug("Monitoring already paused by another operation"); - return false; - } - } - - /** - * Thread-safe resume of monitoring service with a 5-second delay. - * The delay is critical to prevent race conditions where file operations - * are still settling when monitoring resumes. - * - * @param didPause true if this operation paused monitoring - * @param operationName name of the operation for logging - */ - private void resumeMonitoringSafely(boolean didPause, String operationName) { - if (!didPause) { - log.debug("Monitoring was not paused by {} - no resume needed", operationName); - return; - } - - // Use virtual thread for delayed resume to avoid blocking - Thread.startVirtualThread(() -> { - try { - Thread.sleep(5_000); // Critical 5-second delay for filesystem operations to settle - - synchronized (monitoringLock) { - // Double-check that monitoring is still paused before resuming - if (monitoringService.isPaused()) { - monitoringService.resumeMonitoring(); - log.debug("Monitoring resumed after {} completed with 5s delay", operationName); - } else { - log.warn("Monitoring was already resumed by another thread during {}", operationName); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while delaying resume of monitoring after {}", operationName); - } - }); - } - - /** - * Checks if monitoring is currently paused. - * - * @return true if monitoring is paused - */ - public boolean isMonitoringPaused() { - synchronized (monitoringLock) { - return monitoringService.isPaused(); - } - } -} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationService.java new file mode 100644 index 000000000..951f54bcf --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationService.java @@ -0,0 +1,81 @@ +package com.adityachandel.booklore.service.monitoring; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.file.Files; +import java.nio.file.Path; + +@Slf4j +@Service +@AllArgsConstructor +public class MonitoringRegistrationService { + + private final MonitoringService monitoringService; + + + /** + * Checks if a specific path is currently being monitored. + * + * @param path the path to check + * @return true if the path is monitored + */ + public boolean isPathMonitored(Path path) { + return monitoringService.isPathMonitored(path); + } + + /** + * Unregisters a specific path from monitoring without affecting other paths. + * This is more efficient than pausing all monitoring for single path operations. + * + * @param path the path to unregister + */ + public void unregisterSpecificPath(Path path) { + monitoringService.unregisterPath(path); + } + + /** + * Registers a specific path for monitoring. + * + * @param path the path to register + * @param libraryId the library ID associated with this path + */ + public void registerSpecificPath(Path path, Long libraryId) { + monitoringService.registerPath(path, libraryId); + } + + /** + * Unregisters an entire library from monitoring. + * This is more efficient for batch operations than unregistering individual paths. + * + * @param libraryId the library ID to unregister + */ + public void unregisterLibrary(Long libraryId) { + monitoringService.unregisterLibrary(libraryId); + } + + /** + * Re-registers an entire library for monitoring after batch operations. + * Since MonitoringService.registerLibrary() requires a Library object, + * this method will register individual paths under the library instead. + * + * @param libraryId the library ID to register + * @param libraryRoot the root path of the library + */ + public void registerLibraryPaths(Long libraryId, Path libraryRoot) { + if (!Files.exists(libraryRoot) || !Files.isDirectory(libraryRoot)) { + return; + } + try { + monitoringService.registerPath(libraryRoot, libraryId); + try (var stream = Files.walk(libraryRoot)) { + stream.filter(Files::isDirectory) + .filter(path -> !path.equals(libraryRoot)) + .forEach(path -> monitoringService.registerPath(path, libraryId)); + } + } catch (Exception e) { + log.error("Failed to register library paths for libraryId {} at {}", libraryId, libraryRoot, e); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java index 8c9351baa..606e99bf3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java @@ -29,14 +29,11 @@ public class MonitoringService { private final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); private final Set monitoredPaths = ConcurrentHashMap.newKeySet(); + private final Map registeredWatchKeys = new ConcurrentHashMap<>(); private final Map pathToLibraryIdMap = new ConcurrentHashMap<>(); private final Map libraryWatchStatusMap = new ConcurrentHashMap<>(); - private final Map registeredWatchKeys = new ConcurrentHashMap<>(); private final Map> libraryIdToPaths = new ConcurrentHashMap<>(); - private int pauseCount = 0; - private final Object pauseLock = new Object(); - public MonitoringService(LibraryFileEventProcessor libraryFileEventProcessor, WatchService watchService, MonitoringTask monitoringTask) { this.libraryFileEventProcessor = libraryFileEventProcessor; this.watchService = watchService; @@ -60,60 +57,6 @@ public class MonitoringService { } } - public synchronized void pauseMonitoring() { - pauseCount++; - if (pauseCount == 1) { - int count = 0; - for (Path path : new HashSet<>(monitoredPaths)) { - unregisterPath(path, false); - count++; - } - log.info("Monitoring paused ({} paths unregistered, pauseCount={})", count, pauseCount); - } else { - log.info("Monitoring pause requested (pauseCount={})", pauseCount); - } - } - - public synchronized void resumeMonitoring() { - if (pauseCount == 0) { - log.warn("resumeMonitoring() called but monitoring is not paused"); - return; - } - - pauseCount--; - if (pauseCount == 0) { - libraryIdToPaths.forEach((libraryId, rootPaths) -> { - for (Path rootPath : rootPaths) { - if (Files.exists(rootPath)) { - try (Stream stream = Files.walk(rootPath)) { - stream.filter(Files::isDirectory).forEach(path -> { - if (Files.exists(path)) { - registerPath(path, libraryId); - } - }); - } catch (IOException e) { - log.warn("Failed to walk path during resume: {}", rootPath, e); - } - } else { - log.debug("Skipping registration of non-existent path during resume: {}", rootPath); - } - } - }); - - synchronized (pauseLock) { - pauseLock.notifyAll(); - } - - log.info("Monitoring resumed"); - } else { - log.info("Monitoring resume requested (pauseCount={}), monitoring still paused", pauseCount); - } - } - - public synchronized boolean isPaused() { - return pauseCount > 0; - } - public void registerLibraries(List libraries) { libraries.forEach(lib -> libraryWatchStatusMap.put(lib.getId(), lib.isWatch())); libraries.stream().filter(Library::isWatch).forEach(this::registerLibrary); @@ -159,19 +102,7 @@ public class MonitoringService { libraryWatchStatusMap.put(libraryId, false); libraryIdToPaths.remove(libraryId); - log.info("Unregistered library {} from monitoring", libraryId); - } - - public void unregisterLibraries(Set libraryIds) { - libraryIds.forEach(this::unregisterLibrary); - } - - public boolean isLibraryWatched(Long libraryId) { - return libraryWatchStatusMap.getOrDefault(libraryId, false); - } - - public boolean isRelevantBookFile(Path path) { - return BookFileExtension.fromFileName(path.getFileName().toString()).isPresent(); + log.debug("Unregistered library {} from monitoring", libraryId); } public synchronized boolean registerPath(Path path, Long libraryId) { @@ -201,11 +132,21 @@ public class MonitoringService { if (key != null) key.cancel(); pathToLibraryIdMap.remove(path); if (logUnregister) { - log.info("Unregistered path: {}", path); + log.debug("Unregistered path: {}", path); } } } + private void unregisterSubPaths(Path deletedPath) { + Set toRemove = monitoredPaths.stream() + .filter(p -> p.startsWith(deletedPath)) + .collect(Collectors.toSet()); + + for (Path path : toRemove) { + unregisterPath(path); + } + } + @EventListener public void handleFileChangeEvent(FileChangeEvent event) { Path fullPath = event.getFilePath(); @@ -220,26 +161,8 @@ public class MonitoringService { boolean isRelevantFile = isRelevantBookFile(fullPath); if (!(isDir || isRelevantFile)) return; - if (isDir && kind == StandardWatchEventKinds.ENTRY_CREATE) { - Long parentLibraryId = pathToLibraryIdMap.get(event.getWatchedFolder()); - if (parentLibraryId != null) { - try (Stream stream = Files.walk(fullPath)) { - stream.filter(Files::isDirectory).forEach(path -> registerPath(path, parentLibraryId)); - } catch (IOException e) { - log.warn("Failed to register nested paths: {}", fullPath, e); - } - } - } - - if (isDir && kind == StandardWatchEventKinds.ENTRY_DELETE) { - unregisterSubPaths(fullPath); - } - - if (!eventQueue.offer(event)) { - log.warn("Event queue full, dropping: {}", fullPath); - } else { - log.debug("Queued: {} [{}]", fullPath, kind.name()); - } + handleDirectoryEvents(event, fullPath, kind, isDir); + queueEvent(event, fullPath, kind); } @EventListener @@ -258,15 +181,8 @@ public class MonitoringService { singleThreadExecutor.submit(() -> { while (!Thread.currentThread().isInterrupted()) { try { - synchronized (pauseLock) { - while (isPaused()) { - pauseLock.wait(); - } - } - FileChangeEvent event = eventQueue.take(); processFileChangeEvent(event); - } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; @@ -284,9 +200,7 @@ public class MonitoringService { if (libraryId != null) { try { - libraryFileEventProcessor.processFile( - event.getEventKind(), libraryId, watchedFolder.toString(), filePath.toString() - ); + libraryFileEventProcessor.processFile(event.getEventKind(), libraryId, watchedFolder.toString(), filePath.toString()); } catch (InvalidDataAccessApiUsageException e) { log.debug("InvalidDataAccessApiUsageException for libraryId={}", libraryId); } @@ -295,13 +209,36 @@ public class MonitoringService { } } - private void unregisterSubPaths(Path deletedPath) { - Set toRemove = monitoredPaths.stream() - .filter(p -> p.startsWith(deletedPath)) - .collect(Collectors.toSet()); + private void handleDirectoryEvents(FileChangeEvent event, Path fullPath, WatchEvent.Kind kind, boolean isDir) { + if (isDir && kind == StandardWatchEventKinds.ENTRY_CREATE) { + Long parentLibraryId = pathToLibraryIdMap.get(event.getWatchedFolder()); + if (parentLibraryId != null) { + try (Stream stream = Files.walk(fullPath)) { + stream.filter(Files::isDirectory).forEach(path -> registerPath(path, parentLibraryId)); + } catch (IOException e) { + log.warn("Failed to register nested paths: {}", fullPath, e); + } + } + } - for (Path path : toRemove) { - unregisterPath(path); + if (isDir && kind == StandardWatchEventKinds.ENTRY_DELETE) { + unregisterSubPaths(fullPath); } } -} \ No newline at end of file + + private void queueEvent(FileChangeEvent event, Path fullPath, WatchEvent.Kind kind) { + if (!eventQueue.offer(event)) { + log.warn("Event queue full, dropping: {}", fullPath); + } else { + log.debug("Queued: {} [{}]", fullPath, kind.name()); + } + } + + public boolean isRelevantBookFile(Path path) { + return BookFileExtension.fromFileName(path.getFileName().toString()).isPresent(); + } + + public boolean isPathMonitored(Path path) { + return monitoredPaths.contains(path.toAbsolutePath().normalize()); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java index 08f400363..b6c9623e3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java @@ -6,6 +6,8 @@ import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer; import com.adityachandel.booklore.model.dto.*; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.repository.ShelfRepository; +import com.adityachandel.booklore.service.library.LibraryService; import com.adityachandel.booklore.service.BookQueryService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -15,8 +17,11 @@ import org.springframework.stereotype.Service; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; @Slf4j @Service @@ -27,25 +32,283 @@ public class OpdsService { private final AuthenticationService authenticationService; private final UserRepository userRepository; private final BookLoreUserTransformer bookLoreUserTransformer; + private final ShelfRepository shelfRepository; + private final LibraryService libraryService; + public String generateCatalogFeed(HttpServletRequest request) { - List books = getAllowedBooks(null); - String feedVersion = extractVersionFromAcceptHeader(request); + Long libraryId = parseLongParam(request, "libraryId", null); + Long shelfId = parseLongParam(request, "shelfId", null); + String forceV2 = (libraryId != null || shelfId != null) ? "2.0" : null; + String feedVersion = forceV2 != null ? forceV2 : extractVersionFromAcceptHeader(request); return switch (feedVersion) { - case "2.0" -> generateOpdsV2Feed(books); - default -> generateOpdsV1Feed(books, request); + case "2.0" -> { + int page = parseIntParam(request, "page", 1); + int size = parseIntParam(request, "size", 50); + var result = getAllowedBooksPage(null, libraryId, shelfId, page, size); + var qp = new java.util.LinkedHashMap(); + if (libraryId != null) qp.put("libraryId", String.valueOf(libraryId)); + if (shelfId != null) qp.put("shelfId", String.valueOf(shelfId)); + yield generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/catalog", qp, page, size); + } + default -> { + List books = getAllowedBooks(null); + yield generateOpdsV1Feed(books, request); + } }; } public String generateSearchResults(HttpServletRequest request, String queryParam) { - List books = getAllowedBooks(queryParam); - String feedVersion = extractVersionFromAcceptHeader(request); + Long libraryId = parseLongParam(request, "libraryId", null); + Long shelfId = parseLongParam(request, "shelfId", null); + String forceV2 = (libraryId != null || shelfId != null) ? "2.0" : null; + String feedVersion = forceV2 != null ? forceV2 : extractVersionFromAcceptHeader(request); return switch (feedVersion) { - case "2.0" -> generateOpdsV2Feed(books); - default -> generateOpdsV1Feed(books, request); + case "2.0" -> { + int page = parseIntParam(request, "page", 1); + int size = parseIntParam(request, "size", 50); + String q = request.getParameter("q"); + var result = getAllowedBooksPage(q, libraryId, shelfId, page, size); + var qp = new java.util.LinkedHashMap(); + if (q != null && !q.isBlank()) qp.put("q", q); + if (libraryId != null) qp.put("libraryId", String.valueOf(libraryId)); + if (shelfId != null) qp.put("shelfId", String.valueOf(shelfId)); + yield generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/search", qp, page, size); + } + default -> { + List books = getAllowedBooks(queryParam); + yield generateOpdsV1Feed(books, request); + } }; } + public String generateRecentFeed(HttpServletRequest request) { + int page = parseIntParam(request, "page", 1); + int size = parseIntParam(request, "size", 50); + + // Determine context: legacy OPDS user vs OPDS v2 user + OpdsUserDetails details = authenticationService.getOpdsUser(); + OpdsUser opdsUser = details.getOpdsUser(); + + var qp = new java.util.LinkedHashMap(); + qp.put("page", String.valueOf(page)); + qp.put("size", String.valueOf(size)); + + if (opdsUser != null) { + var result = bookQueryService.getRecentBooksPage(true, page, size); + return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size); + } + + OpdsUserV2 v2 = details.getOpdsUserV2(); + BookLoreUserEntity entity = userRepository.findById(v2.getUserId()) + .orElseThrow(() -> new org.springframework.security.access.AccessDeniedException("User not found")); + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + boolean isAdmin = user.getPermissions().isAdmin(); + if (isAdmin) { + var result = bookQueryService.getRecentBooksPage(true, page, size); + return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size); + } + + java.util.Set libraryIds = user.getAssignedLibraries().stream() + .map(Library::getId) + .collect(java.util.stream.Collectors.toSet()); + var result = bookQueryService.getRecentBooksByLibraryIdsPage(libraryIds, true, page, size); + return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size); + } + + public String generateOpdsV2Navigation(HttpServletRequest request) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + String rootPath = "/api/v2/opds"; + var root = new java.util.LinkedHashMap(); + + // metadata + var meta = new java.util.LinkedHashMap(); + meta.put("title", "Booklore"); + root.put("metadata", meta); + + // links: self, start, search + var links = new java.util.ArrayList>(); + links.add(java.util.Map.of( + "rel", "self", + "href", rootPath, + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "start", + "href", rootPath, + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "search", + "href", rootPath + "/search.opds", + "type", "application/opensearchdescription+xml" + )); + root.put("links", links); + + // navigation items + var navigation = new java.util.ArrayList>(); + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", "All Books", + "href", rootPath + "/catalog", + "type", "application/opds+json;profile=acquisition" + ))); + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", "Recently Added", + "href", rootPath + "/recent", + "type", "application/opds+json;profile=acquisition" + ))); + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", "Libraries", + "href", rootPath + "/libraries", + "type", "application/opds+json;profile=navigation" + ))); + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", "Shelves", + "href", rootPath + "/shelves", + "type", "application/opds+json;profile=navigation" + ))); + + + // Enrich with libraries and shelves for OPDS v2 users + OpdsUserDetails details = authenticationService.getOpdsUser(); + if (details != null && details.getOpdsUserV2() != null) { + Long userId = details.getOpdsUserV2().getUserId(); + BookLoreUserEntity entity = userRepository.findById(userId) + .orElseThrow(() -> new org.springframework.security.access.AccessDeniedException("User not found")); + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + + java.util.List libraries; + try { + libraries = libraryService.getLibraries(); + } catch (Exception ex) { + libraries = user.getAssignedLibraries(); + } + // Keep root clean: only references to collections; no inline lists + if (libraries != null) { + // optionally keep this empty or add a count in future + } + } + root.put("navigation", navigation); + + return mapper.writeValueAsString(root); + } catch (Exception e) { + log.error("Failed generating OPDS v2 navigation collection", e); + throw new RuntimeException("Failed generating OPDS v2 navigation collection", e); + } + } + + public String generateOpdsV2LibrariesNavigation(HttpServletRequest request) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + String rootPath = "/api/v2/opds"; + String selfPath = rootPath + "/libraries"; + var root = new java.util.LinkedHashMap(); + + // metadata + var meta = new java.util.LinkedHashMap(); + meta.put("title", "Libraries"); + root.put("metadata", meta); + + // links + var links = new java.util.ArrayList>(); + links.add(java.util.Map.of( + "rel", "self", + "href", selfPath, + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "start", + "href", rootPath, + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "search", + "href", rootPath + "/search.opds", + "type", "application/opensearchdescription+xml" + )); + root.put("links", links); + + // navigation list of libraries + var navigation = new java.util.ArrayList>(); + + OpdsUserDetails details = authenticationService.getOpdsUser(); + if (details != null && details.getOpdsUserV2() != null) { + Long userId = details.getOpdsUserV2().getUserId(); + BookLoreUserEntity entity = userRepository.findById(userId) + .orElseThrow(() -> new AccessDeniedException("User not found")); + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + + java.util.List libraries = (user.getPermissions() != null && user.getPermissions().isAdmin()) + ? libraryService.getAllLibraries() + : user.getAssignedLibraries(); + if (libraries != null) { + for (Library lib : libraries) { + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", lib.getName(), + "href", buildHref(rootPath + "/catalog", java.util.Map.of("libraryId", String.valueOf(lib.getId()))), + "type", "application/opds+json;profile=acquisition" + ))); + } + } + } + + root.put("navigation", navigation); + return mapper.writeValueAsString(root); + } catch (Exception e) { + log.error("Failed generating OPDS v2 libraries navigation", e); + throw new RuntimeException("Failed generating OPDS v2 libraries navigation", e); + } + } + + + + public String generateOpdsV2ShelvesNavigation(HttpServletRequest request) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + String rootPath = "/api/v2/opds"; + String selfPath = rootPath + "/shelves"; + var root = new java.util.LinkedHashMap(); + + var meta = new java.util.LinkedHashMap(); + meta.put("title", "Shelves"); + root.put("metadata", meta); + + var links = new java.util.ArrayList>(); + links.add(java.util.Map.of("rel", "self", "href", selfPath, "type", "application/opds+json;profile=navigation")); + links.add(java.util.Map.of("rel", "start", "href", rootPath, "type", "application/opds+json;profile=navigation")); + links.add(java.util.Map.of("rel", "search", "href", rootPath + "/search.opds", "type", "application/opensearchdescription+xml")); + root.put("links", links); + + var navigation = new java.util.ArrayList>(); + OpdsUserDetails details = authenticationService.getOpdsUser(); + if (details != null && details.getOpdsUserV2() != null) { + Long userId = details.getOpdsUserV2().getUserId(); + var shelves = shelfRepository.findByUserId(userId); + if (shelves != null) { + for (var shelf : shelves) { + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", shelf.getName(), + "href", buildHref(rootPath + "/catalog", java.util.Map.of("shelfId", String.valueOf(shelf.getId()))), + "type", "application/opds+json;profile=acquisition" + ))); + } + } + } + root.put("navigation", navigation); + return mapper.writeValueAsString(root); + } catch (Exception e) { + log.error("Failed generating OPDS v2 shelves navigation", e); + throw new RuntimeException("Failed generating OPDS v2 shelves navigation", e); + } + } + private List getAllowedBooks(String queryParam) { OpdsUserDetails opdsUserDetails = authenticationService.getOpdsUser(); OpdsUser opdsUser = opdsUserDetails.getOpdsUser(); @@ -92,7 +355,12 @@ public class OpdsService { private String extractVersionFromAcceptHeader(HttpServletRequest request) { var acceptHeader = request.getHeader("Accept"); - return (acceptHeader != null && acceptHeader.contains("version=2.0")) ? "2.0" : "1.2"; + if (acceptHeader == null) return "1.2"; + // Accept either explicit version or generic OPDS 2 media type + if (acceptHeader.contains("version=2.0") || acceptHeader.contains("application/opds+json")) { + return "2.0"; + } + return "1.2"; } private String generateOpdsV1SearchDescription() { @@ -189,14 +457,148 @@ public class OpdsService { } } - private String generateOpdsV2Feed(List books) { - // Placeholder for OPDS v2.0 feed implementation (similar structure as v1) - return "OPDS v2.0 Feed is under construction"; + private String generateOpdsV2Feed(List content, long total, String basePath, java.util.Map queryParams, int page, int size) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + // Root collection + var root = new java.util.LinkedHashMap(); + + // metadata with pagination + var meta = new java.util.LinkedHashMap(); + meta.put("title", "Booklore Catalog"); + if (page < 1) page = 1; + if (size < 1) size = 1; + if (size > 200) size = 200; + meta.put("itemsPerPage", size); + meta.put("currentPage", page); + meta.put("numberOfItems", total); + root.put("metadata", meta); + + // links + var links = new java.util.ArrayList>(); + links.add(java.util.Map.of( + "rel", "self", + "href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page), "size", String.valueOf(size)))), + "type", "application/opds+json;profile=acquisition" + )); + links.add(java.util.Map.of( + "rel", "start", + "href", "/api/v2/opds", + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "search", + "href", "/api/v2/opds/search.opds", + "type", "application/opensearchdescription+xml" + )); + if ((page - 1) > 0) { + links.add(java.util.Map.of( + "rel", "previous", + "href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page - 1), "size", String.valueOf(size)))), + "type", "application/opds+json;profile=acquisition" + )); + } + if ((long) page * size < total) { + links.add(java.util.Map.of( + "rel", "next", + "href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page + 1), "size", String.valueOf(size)))), + "type", "application/opds+json;profile=acquisition" + )); + } + root.put("links", links); + + // publications + var pubs = new java.util.ArrayList>(); + for (Book book : content) { + pubs.add(toPublicationMap(book)); + } + root.put("publications", pubs); + + return mapper.writeValueAsString(root); + } catch (Exception e) { + log.error("Failed generating OPDS v2 feed", e); + throw new RuntimeException("Failed generating OPDS v2 feed", e); + } + } + + public String generateOpdsV2Publication(HttpServletRequest request, long bookId) { + List allowed = getAllowedBooks(null); + Book target = allowed.stream().filter(b -> b.getId() != null && b.getId() == bookId).findFirst() + .orElseThrow(() -> new AccessDeniedException("You are not allowed to access this resource")); + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper.writeValueAsString(toPublicationMap(target)); + } catch (Exception e) { + log.error("Failed generating OPDS v2 publication", e); + throw new RuntimeException("Failed generating OPDS v2 publication", e); + } + } + + private java.util.Map toPublicationMap(Book book) { + String base = "/api/v2/opds"; + var pub = new java.util.LinkedHashMap(); + var pm = new java.util.LinkedHashMap(); + String title = (book.getMetadata() != null ? book.getMetadata().getTitle() : book.getTitle()); + pm.put("title", title != null ? title : "Untitled"); + if (book.getMetadata() != null) { + if (book.getMetadata().getLanguage() != null) { + pm.put("language", book.getMetadata().getLanguage()); + } + if (book.getMetadata().getIsbn13() != null) { + pm.put("identifier", "urn:isbn:" + book.getMetadata().getIsbn13()); + } else if (book.getMetadata().getIsbn10() != null) { + pm.put("identifier", "urn:isbn:" + book.getMetadata().getIsbn10()); + } + if (book.getMetadata().getAuthors() != null && !book.getMetadata().getAuthors().isEmpty()) { + var authors = book.getMetadata().getAuthors().stream() + .map(a -> java.util.Map.of("name", a)) + .collect(java.util.stream.Collectors.toList()); + pm.put("author", authors); + } + if (book.getMetadata().getDescription() != null) { + pm.put("description", book.getMetadata().getDescription()); + } + } + pub.put("metadata", pm); + + var plinks = new java.util.ArrayList>(); + String type = "application/" + fileMimeType(book); + plinks.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "rel", "http://opds-spec.org/acquisition/open-access", + "href", base + "/" + book.getId() + "/download", + "type", type + ))); + plinks.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "rel", "self", + "href", base + "/publications/" + book.getId(), + "type", "application/opds-publication+json" + ))); + pub.put("links", plinks); + + if (book.getMetadata() != null && book.getMetadata().getCoverUpdatedOn() != null) { + var images = new java.util.ArrayList>(); + String coverHref = base + "/" + book.getId() + "/cover?" + book.getMetadata().getCoverUpdatedOn(); + images.add(java.util.Map.of("href", coverHref, "type", "image/jpeg")); + pub.put("images", images); + } + return pub; } private String generateOpdsV2SearchDescription() { - // Placeholder for OPDS v2.0 feed implementation (similar structure as v1) - return "OPDS v2.0 Feed is under construction"; + return """ + + + Booklore catalog (OPDS 2) + Search the Booklore ebook catalog. + + en-us + UTF-8 + UTF-8 + + """; } @@ -227,4 +629,107 @@ public class OpdsService { private String extractVersionFromRequest(HttpServletRequest request) { return (request.getRequestURI() != null && request.getRequestURI().startsWith("/api/v2/opds")) ? "v2" : "v1"; } + + private String urlEncode(String value) { + try { + return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + return value; + } + } + + private int parseIntParam(HttpServletRequest request, String name, int defaultValue) { + try { + String v = request.getParameter(name); + if (v == null || v.isBlank()) return defaultValue; + return Integer.parseInt(v); + } catch (Exception e) { + return defaultValue; + } + } + + private String buildHref(String basePath, java.util.Map params) { + if (params == null || params.isEmpty()) return basePath; + String query = params.entrySet().stream() + .map(e -> urlEncode(e.getKey()) + "=" + urlEncode(e.getValue())) + .collect(java.util.stream.Collectors.joining("&")); + return basePath + (query.isEmpty() ? "" : ("?" + query)); + } + + private java.util.Map mergeQuery(java.util.Map base, java.util.Map extra) { + var map = new java.util.LinkedHashMap(); + if (base != null) map.putAll(base); + if (extra != null) map.putAll(extra); + return map; + } + + private Long parseLongParam(HttpServletRequest request, String name, Long defaultValue) { + try { + String v = request.getParameter(name); + if (v == null || v.isBlank()) return defaultValue; + return Long.parseLong(v); + } catch (Exception e) { + return defaultValue; + } + } + + private org.springframework.data.domain.Page getAllowedBooksPage(String queryParam, Long libraryId, Long shelfId, int page, int size) { + OpdsUserDetails opdsUserDetails = authenticationService.getOpdsUser(); + OpdsUser opdsUser = opdsUserDetails.getOpdsUser(); + + if (opdsUser != null) { + if (shelfId != null) { + return bookQueryService.getAllBooksByShelfPage(shelfId, true, page, size); + } + if (libraryId != null) { + return bookQueryService.getAllBooksByLibraryIdsPage(java.util.Set.of(libraryId), true, page, size); + } + if (queryParam != null && !queryParam.isBlank()) { + return bookQueryService.searchBooksByMetadataPage(queryParam, page, size); + } + return bookQueryService.getAllBooksPage(true, page, size); + } + + OpdsUserV2 opdsUserV2 = opdsUserDetails.getOpdsUserV2(); + BookLoreUserEntity entity = userRepository.findById(opdsUserV2.getUserId()) + .orElseThrow(() -> new AccessDeniedException("User not found")); + + if (!entity.getPermissions().isPermissionAccessOpds() && !entity.getPermissions().isPermissionAdmin()) { + throw new AccessDeniedException("You are not allowed to access this resource"); + } + + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + boolean isAdmin = user.getPermissions().isAdmin(); + java.util.Set libraryIds = user.getAssignedLibraries().stream() + .map(Library::getId) + .collect(java.util.stream.Collectors.toSet()); + + if (shelfId != null) { + var shelf = shelfRepository.findById(shelfId).orElseThrow(() -> new AccessDeniedException("Shelf not found")); + if (!shelf.getUser().getId().equals(user.getId()) && !isAdmin) { + throw new AccessDeniedException("You are not allowed to access this shelf"); + } + return bookQueryService.getAllBooksByShelfPage(shelfId, true, page, size); + } + + if (libraryId != null) { + if (!isAdmin && !libraryIds.contains(libraryId)) { + throw new AccessDeniedException("You are not allowed to access this library"); + } + return (queryParam != null && !queryParam.isBlank()) + ? bookQueryService.searchBooksByMetadataInLibrariesPage(queryParam, java.util.Set.of(libraryId), page, size) + : bookQueryService.getAllBooksByLibraryIdsPage(java.util.Set.of(libraryId), true, page, size); + } + + if (isAdmin) { + return (queryParam != null && !queryParam.isBlank()) + ? bookQueryService.searchBooksByMetadataPage(queryParam, page, size) + : bookQueryService.getAllBooksPage(true, page, size); + } + + return (queryParam != null && !queryParam.isBlank()) + ? bookQueryService.searchBooksByMetadataInLibrariesPage(queryParam, libraryIds, page, size) + : bookQueryService.getAllBooksByLibraryIdsPage(libraryIds, true, page, size); + } + } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java index 045ef0484..f172409d4 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java @@ -3,50 +3,36 @@ package com.adityachandel.booklore.service.upload; import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.AdditionalFileMapper; -import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.AdditionalFile; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookMetadata; -import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.model.enums.AdditionalFileType; import com.adityachandel.booklore.model.enums.BookFileExtension; -import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.model.enums.FileProcessStatus; -import com.adityachandel.booklore.model.websocket.Topic; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.FileFingerprint; -import com.adityachandel.booklore.service.NotificationService; import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; -import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; +import com.adityachandel.booklore.service.file.FileMovingHelper; import com.adityachandel.booklore.service.metadata.extractor.EpubMetadataExtractor; import com.adityachandel.booklore.service.metadata.extractor.PdfMetadataExtractor; -import com.adityachandel.booklore.service.monitoring.MonitoringService; -import com.adityachandel.booklore.util.FileUtils; import com.adityachandel.booklore.util.PathPatternResolver; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; - -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; -import java.nio.file.*; -import java.nio.file.attribute.GroupPrincipal; -import java.nio.file.attribute.PosixFileAttributeView; -import java.nio.file.attribute.UserPrincipal; -import java.nio.file.attribute.UserPrincipalLookupService; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Instant; -import java.util.Objects; import java.util.Optional; @RequiredArgsConstructor @@ -54,202 +40,190 @@ import java.util.Optional; @Slf4j public class FileUploadService { + private static final String UPLOAD_TEMP_PREFIX = "upload-"; + private static final String BOOKDROP_TEMP_PREFIX = "bookdrop-"; + private static final long BYTES_TO_KB_DIVISOR = 1024L; + private static final long MB_TO_BYTES_MULTIPLIER = 1024L * 1024L; + private final LibraryRepository libraryRepository; private final BookRepository bookRepository; private final BookAdditionalFileRepository additionalFileRepository; - private final BookFileProcessorRegistry processorRegistry; - private final NotificationService notificationService; private final AppSettingService appSettingService; private final AppProperties appProperties; private final PdfMetadataExtractor pdfMetadataExtractor; private final EpubMetadataExtractor epubMetadataExtractor; private final AdditionalFileMapper additionalFileMapper; - private final MonitoringService monitoringService; + private final FileMovingHelper fileMovingHelper; - @Value("${PUID:${USER_ID:0}}") - private String userId; - - @Value("${PGID:${GROUP_ID:0}}") - private String groupId; - - public Book uploadFile(MultipartFile file, long libraryId, long pathId) throws IOException { + public void uploadFile(MultipartFile file, long libraryId, long pathId) { validateFile(file); - LibraryEntity libraryEntity = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); - - LibraryPathEntity libraryPathEntity = libraryEntity.getLibraryPaths() - .stream() - .filter(p -> p.getId() == pathId) - .findFirst() - .orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryId)); - - Path tempPath = Files.createTempFile("upload-", Objects.requireNonNull(file.getOriginalFilename())); - FileProcessResult result; - - boolean wePaused = false; - if (!monitoringService.isPaused()) { - monitoringService.pauseMonitoring(); - wePaused = true; - } + final LibraryEntity libraryEntity = findLibraryById(libraryId); + final LibraryPathEntity libraryPathEntity = findLibraryPathById(libraryEntity, pathId); + final String originalFileName = getValidatedFileName(file); + Path tempPath = null; try { + tempPath = createTempFile(UPLOAD_TEMP_PREFIX, originalFileName); file.transferTo(tempPath); - setTemporaryFileOwnership(tempPath); - BookFileExtension fileExt = BookFileExtension.fromFileName(file.getOriginalFilename()).orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")); - BookMetadata metadata = extractMetadata(fileExt, tempPath.toFile()); - String uploadPattern = appSettingService.getAppSettings().getUploadPattern(); - if (uploadPattern.endsWith("/") || uploadPattern.endsWith("\\")) { - uploadPattern += "{currentFilename}"; - } - String relativePath = PathPatternResolver.resolvePattern(metadata, uploadPattern, file.getOriginalFilename()); - Path finalPath = Paths.get(libraryPathEntity.getPath(), relativePath); - File finalFile = finalPath.toFile(); + final BookFileExtension fileExtension = getFileExtension(originalFileName); + final BookMetadata metadata = extractMetadata(fileExtension, tempPath.toFile()); + final String uploadPattern = fileMovingHelper.getFileNamingPattern(libraryEntity); - if (finalFile.exists()) { - throw ApiError.FILE_ALREADY_EXISTS.createException(); - } + final String relativePath = PathPatternResolver.resolvePattern(metadata, uploadPattern, originalFileName); + final Path finalPath = Paths.get(libraryPathEntity.getPath(), relativePath); - Files.createDirectories(finalPath.getParent()); - Files.move(tempPath, finalPath); + validateFinalPath(finalPath); + moveFileToFinalLocation(tempPath, finalPath); log.info("File uploaded to final location: {}", finalPath); - result = processFile(finalFile.getName(), libraryEntity, libraryPathEntity, finalFile, fileExt.getType()); - if (result != null && result.getStatus() != FileProcessStatus.DUPLICATE) { - notificationService.sendMessage(Topic.BOOK_ADD, result.getBook()); - } - - return result.getBook(); - } catch (IOException e) { + log.error("Failed to upload file: {}", originalFileName, e); throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); } finally { - Files.deleteIfExists(tempPath); - - if (wePaused) { - Thread.startVirtualThread(() -> { - try { - Thread.sleep(5_000); - monitoringService.resumeMonitoring(); - log.info("Monitoring resumed after 5s delay"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while delaying resume of monitoring"); - } - }); - } + cleanupTempFile(tempPath); } } @Transactional public AdditionalFile uploadAdditionalFile(Long bookId, MultipartFile file, AdditionalFileType additionalFileType, String description) throws IOException { - Optional bookOpt = bookRepository.findById(bookId); - if (bookOpt.isEmpty()) { - throw new IllegalArgumentException("Book not found with id: " + bookId); - } - - BookEntity book = bookOpt.get(); - String originalFileName = file.getOriginalFilename(); - if (originalFileName == null) { - throw new IllegalArgumentException("File must have a name"); - } - - Path tempPath = Files.createTempFile("upload-", Objects.requireNonNull(file.getOriginalFilename())); - - boolean wePaused = false; - if (!monitoringService.isPaused()) { - monitoringService.pauseMonitoring(); - wePaused = true; - } + final BookEntity book = findBookById(bookId); + final String originalFileName = getValidatedFileName(file); + Path tempPath = null; try { + tempPath = createTempFile(UPLOAD_TEMP_PREFIX, originalFileName); file.transferTo(tempPath); - setTemporaryFileOwnership(tempPath); + final String fileHash = FileFingerprint.generateHash(tempPath); + validateAlternativeFormatDuplicate(additionalFileType, fileHash); - // Check for duplicates by hash, but only for alternative formats - String fileHash = FileFingerprint.generateHash(tempPath); - if (additionalFileType == AdditionalFileType.ALTERNATIVE_FORMAT) { - Optional existingAltFormat = additionalFileRepository.findByAltFormatCurrentHash(fileHash); - if (existingAltFormat.isPresent()) { - throw new IllegalArgumentException("Alternative format file already exists with same content"); - } - } - - // Store file in same directory as the book - Path finalPath = Paths.get(book.getLibraryPath().getPath(), book.getFileSubPath(), originalFileName); - File finalFile = finalPath.toFile(); - - if (finalFile.exists()) { - throw ApiError.FILE_ALREADY_EXISTS.createException(); - } - - Files.createDirectories(finalPath.getParent()); - Files.move(tempPath, finalPath); + final Path finalPath = buildAdditionalFilePath(book, originalFileName); + validateFinalPath(finalPath); + moveFileToFinalLocation(tempPath, finalPath); log.info("Additional file uploaded to final location: {}", finalPath); - // Create entity - BookAdditionalFileEntity entity = BookAdditionalFileEntity.builder() - .book(book) - .fileName(originalFileName) - .fileSubPath(book.getFileSubPath()) - .additionalFileType(additionalFileType) - .fileSizeKb(file.getSize() / 1024) - .initialHash(fileHash) - .currentHash(fileHash) - .description(description) - .addedOn(Instant.now()) - .build(); + final BookAdditionalFileEntity entity = createAdditionalFileEntity(book, originalFileName, additionalFileType, file.getSize(), fileHash, description); + final BookAdditionalFileEntity savedEntity = additionalFileRepository.save(entity); - entity = additionalFileRepository.save(entity); + return additionalFileMapper.toAdditionalFile(savedEntity); - return additionalFileMapper.toAdditionalFile(entity); } catch (IOException e) { + log.error("Failed to upload additional file for book {}: {}", bookId, originalFileName, e); throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); } finally { - Files.deleteIfExists(tempPath); - - if (wePaused) { - Thread.startVirtualThread(() -> { - try { - Thread.sleep(5_000); - monitoringService.resumeMonitoring(); - log.info("Monitoring resumed after 5s delay"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while delaying resume of monitoring"); - } - }); - } + cleanupTempFile(tempPath); } } public Book uploadFileBookDrop(MultipartFile file) throws IOException { validateFile(file); - Path dropFolder = Paths.get(appProperties.getBookdropFolder()); + final Path dropFolder = Paths.get(appProperties.getBookdropFolder()); Files.createDirectories(dropFolder); - String originalFilename = Objects.requireNonNull(file.getOriginalFilename()); - Path tempPath = Files.createTempFile("bookdrop-", originalFilename); + final String originalFilename = getValidatedFileName(file); + Path tempPath = null; try { + tempPath = createTempFile(BOOKDROP_TEMP_PREFIX, originalFilename); file.transferTo(tempPath); - setTemporaryFileOwnership(tempPath); - Path finalPath = dropFolder.resolve(originalFilename); - - if (Files.exists(finalPath)) { - throw ApiError.FILE_ALREADY_EXISTS.createException(); - } + final Path finalPath = dropFolder.resolve(originalFilename); + validateFinalPath(finalPath); Files.move(tempPath, finalPath); log.info("File moved to book-drop folder: {}", finalPath); return null; + } finally { - Files.deleteIfExists(tempPath); + cleanupTempFile(tempPath); + } + } + + private LibraryEntity findLibraryById(long libraryId) { + return libraryRepository.findById(libraryId) + .orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); + } + + private LibraryPathEntity findLibraryPathById(LibraryEntity libraryEntity, long pathId) { + return libraryEntity.getLibraryPaths() + .stream() + .filter(p -> p.getId() == pathId) + .findFirst() + .orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryEntity.getId())); + } + + private BookEntity findBookById(Long bookId) { + return bookRepository.findById(bookId) + .orElseThrow(() -> new IllegalArgumentException("Book not found with id: " + bookId)); + } + + private String getValidatedFileName(MultipartFile file) { + final String originalFileName = file.getOriginalFilename(); + if (originalFileName == null) { + throw new IllegalArgumentException("File must have a name"); + } + return originalFileName; + } + + private BookFileExtension getFileExtension(String fileName) { + return BookFileExtension.fromFileName(fileName) + .orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")); + } + + private Path createTempFile(String prefix, String fileName) throws IOException { + return Files.createTempFile(prefix, fileName); + } + + private void validateFinalPath(Path finalPath) { + if (Files.exists(finalPath)) { + throw ApiError.FILE_ALREADY_EXISTS.createException(); + } + } + + private void moveFileToFinalLocation(Path sourcePath, Path targetPath) throws IOException { + Files.createDirectories(targetPath.getParent()); + Files.move(sourcePath, targetPath); + } + + private void validateAlternativeFormatDuplicate(AdditionalFileType additionalFileType, String fileHash) { + if (additionalFileType == AdditionalFileType.ALTERNATIVE_FORMAT) { + final Optional existingAltFormat = additionalFileRepository.findByAltFormatCurrentHash(fileHash); + if (existingAltFormat.isPresent()) { + throw new IllegalArgumentException("Alternative format file already exists with same content"); + } + } + } + + private Path buildAdditionalFilePath(BookEntity book, String fileName) { + return Paths.get(book.getLibraryPath().getPath(), book.getFileSubPath(), fileName); + } + + private BookAdditionalFileEntity createAdditionalFileEntity(BookEntity book, String fileName, AdditionalFileType additionalFileType, long fileSize, String fileHash, String description) { + return BookAdditionalFileEntity.builder() + .book(book) + .fileName(fileName) + .fileSubPath(book.getFileSubPath()) + .additionalFileType(additionalFileType) + .fileSizeKb(fileSize / BYTES_TO_KB_DIVISOR) + .initialHash(fileHash) + .currentHash(fileHash) + .description(description) + .addedOn(Instant.now()) + .build(); + } + + private void cleanupTempFile(Path tempPath) { + if (tempPath != null) { + try { + Files.deleteIfExists(tempPath); + } catch (IOException e) { + log.warn("Failed to cleanup temp file: {}", tempPath, e); + } } } @@ -262,41 +236,14 @@ public class FileUploadService { } private void validateFile(MultipartFile file) { - String originalFilename = file.getOriginalFilename(); + final String originalFilename = file.getOriginalFilename(); if (originalFilename == null || BookFileExtension.fromFileName(originalFilename).isEmpty()) { throw ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension"); } - int maxSizeMb = appSettingService.getAppSettings().getMaxFileUploadSizeInMb(); - if (file.getSize() > maxSizeMb * 1024L * 1024L) { + + final int maxSizeMb = appSettingService.getAppSettings().getMaxFileUploadSizeInMb(); + if (file.getSize() > maxSizeMb * MB_TO_BYTES_MULTIPLIER) { throw ApiError.FILE_TOO_LARGE.createException(maxSizeMb); } } - - private void setTemporaryFileOwnership(Path tempPath) throws IOException { - UserPrincipalLookupService lookupService = FileSystems.getDefault() - .getUserPrincipalLookupService(); - if (!userId.equals("0")) { - UserPrincipal user = lookupService.lookupPrincipalByName(userId); - Files.getFileAttributeView(tempPath, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS).setOwner(user); - } - if (!groupId.equals("0")) { - GroupPrincipal group = lookupService.lookupPrincipalByGroupName(groupId); - Files.getFileAttributeView(tempPath, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS).setGroup(group); - } - } - - private FileProcessResult processFile(String fileName, LibraryEntity libraryEntity, LibraryPathEntity libraryPathEntity, File storageFile, BookFileType type) { - String subPath = FileUtils.getRelativeSubPath(libraryPathEntity.getPath(), storageFile.toPath()); - - LibraryFile libraryFile = LibraryFile.builder() - .libraryEntity(libraryEntity) - .libraryPathEntity(libraryPathEntity) - .fileSubPath(subPath) - .bookFileType(type) - .fileName(fileName) - .build(); - - BookFileProcessor processor = processorRegistry.getProcessorOrThrow(type); - return processor.processFile(libraryFile); - } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java index 46c475fbd..9b60644d5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java @@ -14,6 +14,9 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -43,6 +46,7 @@ public class FileService { // @formatter:off private static final String IMAGES_DIR = "images"; + private static final String BACKGROUNDS_DIR = "backgrounds"; private static final String THUMBNAIL_FILENAME = "thumbnail.jpg"; private static final String COVER_FILENAME = "cover.jpg"; private static final String JPEG_MIME_TYPE = "image/jpeg"; @@ -53,37 +57,80 @@ public class FileService { private static final String IMAGE_FORMAT = "JPEG"; // @formatter:on - public void createThumbnailFromFile(long bookId, MultipartFile file) { - try { - validateCoverFile(file); - BufferedImage originalImage = ImageIO.read(file.getInputStream()); - if (originalImage == null) { - throw ApiError.IMAGE_NOT_FOUND.createException(); - } - boolean success = saveCoverImages(originalImage, bookId); - if (!success) { - throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); - } - log.info("Cover images created and saved for book ID: {}", bookId); - } catch (Exception e) { - log.error("An error occurred while creating the thumbnail: {}", e.getMessage(), e); - throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); + // ======================================== + // PATH UTILITIES + // ======================================== + + public String getImagesFolder(long bookId) { + return Paths.get(appProperties.getPathConfig(), IMAGES_DIR, String.valueOf(bookId)).toString(); + } + + public String getThumbnailFile(long bookId) { + return Paths.get(appProperties.getPathConfig(), IMAGES_DIR, String.valueOf(bookId), THUMBNAIL_FILENAME).toString(); + } + + public String getCoverFile(long bookId) { + return Paths.get(appProperties.getPathConfig(), IMAGES_DIR, String.valueOf(bookId), COVER_FILENAME).toString(); + } + + public String getBackgroundsFolder(Long userId) { + if (userId != null) { + return Paths.get(appProperties.getPathConfig(), BACKGROUNDS_DIR, "user-" + userId).toString(); + } + return Paths.get(appProperties.getPathConfig(), BACKGROUNDS_DIR).toString(); + } + + public String getBackgroundsFolder() { + return getBackgroundsFolder(null); + } + + public String getBackgroundUrl(String filename, Long userId) { + if (userId != null) { + return Paths.get("/", BACKGROUNDS_DIR, "user-" + userId, filename).toString().replace("\\", "/"); + } + return Paths.get("/", BACKGROUNDS_DIR, filename).toString().replace("\\", "/"); + } + + public String getMetadataBackupPath() { + return Paths.get(appProperties.getPathConfig(), "metadata_backup").toString(); + } + + public String getBookMetadataBackupPath(long bookId) { + return Paths.get(appProperties.getPathConfig(), "metadata_backup", String.valueOf(bookId)).toString(); + } + + public String getCbxCachePath() { + return Paths.get(appProperties.getPathConfig(), "cbx_cache").toString(); + } + + public String getPdfCachePath() { + return Paths.get(appProperties.getPathConfig(), "pdf_cache").toString(); + } + + public String getTempBookdropCoverImagePath(long bookdropFileId) { + return Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFileId + ".jpg").toString(); + } + + // ======================================== + // VALIDATION + // ======================================== + + private void validateCoverFile(MultipartFile file) { + if (file.isEmpty()) { + throw new IllegalArgumentException("Uploaded file is empty"); + } + String contentType = file.getContentType(); + if (!(JPEG_MIME_TYPE.equalsIgnoreCase(contentType) || PNG_MIME_TYPE.equalsIgnoreCase(contentType))) { + throw new IllegalArgumentException("Only JPEG and PNG files are allowed"); + } + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new IllegalArgumentException("File size must not exceed 5 MB"); } } - public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException { - String folderPath = getImagesFolder(bookId); - File folder = new File(folderPath); - if (!folder.exists() && !folder.mkdirs()) { - throw new IOException("Failed to create directory: " + folder.getAbsolutePath()); - } - File originalFile = new File(folder, COVER_FILENAME); - boolean originalSaved = ImageIO.write(coverImage, IMAGE_FORMAT, originalFile); - BufferedImage thumb = resizeImage(coverImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); - File thumbnailFile = new File(folder, THUMBNAIL_FILENAME); - boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile); - return originalSaved && thumbnailSaved; - } + // ======================================== + // IMAGE OPERATIONS + // ======================================== public BufferedImage resizeImage(BufferedImage originalImage, int width, int height) { Image tmp = originalImage.getScaledInstance(width, height, Image.SCALE_SMOOTH); @@ -105,6 +152,81 @@ public class FileService { log.info("Image saved successfully to: {}", filePath); } + public BufferedImage downloadImageFromUrl(String imageUrl) throws IOException { + try { + URL url = new URL(imageUrl); + BufferedImage image = ImageIO.read(url); + if (image == null) { + throw new IOException("Unable to read image from URL: " + imageUrl); + } + return image; + } catch (Exception e) { + log.error("Failed to download image from URL: {} - {}", imageUrl, e.getMessage()); + throw new IOException("Failed to download image from URL: " + imageUrl, e); + } + } + + // ======================================== + // COVER OPERATIONS + // ======================================== + + public void createThumbnailFromFile(long bookId, MultipartFile file) { + try { + validateCoverFile(file); + BufferedImage originalImage = ImageIO.read(file.getInputStream()); + if (originalImage == null) { + throw ApiError.IMAGE_NOT_FOUND.createException(); + } + boolean success = saveCoverImages(originalImage, bookId); + if (!success) { + throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); + } + log.info("Cover images created and saved for book ID: {}", bookId); + } catch (Exception e) { + log.error("An error occurred while creating the thumbnail: {}", e.getMessage(), e); + throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); + } + } + + public void createThumbnailFromUrl(long bookId, String imageUrl) { + try { + BufferedImage originalImage = downloadImageFromUrl(imageUrl); + boolean success = saveCoverImages(originalImage, bookId); + if (!success) { + throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); + } + log.info("Cover images created and saved from URL for book ID: {}", bookId); + } catch (Exception e) { + log.error("An error occurred while creating thumbnail from URL: {}", e.getMessage(), e); + throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); + } + } + + public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException { + String folderPath = getImagesFolder(bookId); + File folder = new File(folderPath); + if (!folder.exists() && !folder.mkdirs()) { + throw new IOException("Failed to create directory: " + folder.getAbsolutePath()); + } + BufferedImage rgbImage = new BufferedImage( + coverImage.getWidth(), + coverImage.getHeight(), + BufferedImage.TYPE_INT_RGB + ); + Graphics2D g = rgbImage.createGraphics(); + g.drawImage(coverImage, 0, 0, Color.WHITE, null); + g.dispose(); + + File originalFile = new File(folder, COVER_FILENAME); + boolean originalSaved = ImageIO.write(rgbImage, IMAGE_FORMAT, originalFile); + + BufferedImage thumb = resizeImage(rgbImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + File thumbnailFile = new File(folder, THUMBNAIL_FILENAME); + boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile); + + return originalSaved && thumbnailSaved; + } + public void setBookCoverPath(BookMetadataEntity bookMetadataEntity) { bookMetadataEntity.setCoverUpdatedOn(Instant.now()); } @@ -133,12 +255,119 @@ public class FileService { log.info("Deleted {} book covers", bookIds.size()); } + // ======================================== + // BACKGROUND OPERATIONS + // ======================================== + + public void saveBackgroundImage(BufferedImage image, String filename, Long userId) throws IOException { + String backgroundsFolder = getBackgroundsFolder(userId); + File folder = new File(backgroundsFolder); + if (!folder.exists() && !folder.mkdirs()) { + throw new IOException("Failed to create backgrounds directory: " + folder.getAbsolutePath()); + } + + File outputFile = new File(folder, filename); + boolean saved = ImageIO.write(image, IMAGE_FORMAT, outputFile); + if (!saved) { + throw new IOException("Failed to save background image: " + filename); + } + + log.info("Background image saved successfully for user {}: {}", userId, filename); + } + + public void deleteBackgroundFile(String filename, Long userId) { + try { + String backgroundsFolder = getBackgroundsFolder(userId); + File file = new File(backgroundsFolder, filename); + if (file.exists() && file.isFile()) { + boolean deleted = file.delete(); + if (deleted) { + if (userId != null) { + deleteEmptyUserBackgroundFolder(userId); + } + } else { + log.warn("Failed to delete background file for user {}: {}", userId, filename); + } + } + } catch (Exception e) { + log.warn("Error deleting background file {} for user {}: {}", filename, userId, e.getMessage()); + } + } + + private void deleteEmptyUserBackgroundFolder(Long userId) { + try { + String userBackgroundsFolder = getBackgroundsFolder(userId); + File folder = new File(userBackgroundsFolder); + + if (folder.exists() && folder.isDirectory()) { + File[] files = folder.listFiles(); + if (files != null && files.length == 0) { + boolean deleted = folder.delete(); + if (deleted) { + log.info("Deleted empty background folder for user: {}", userId); + } else { + log.warn("Failed to delete empty background folder for user: {}", userId); + } + } + } + } catch (Exception e) { + log.warn("Error checking/deleting empty background folder for user {}: {}", userId, e.getMessage()); + } + } + + public Resource getBackgroundResource(Long userId) { + String[] possibleFiles = {"1.jpg", "1.jpeg", "1.png"}; + + if (userId != null) { + String userBackgroundsFolder = getBackgroundsFolder(userId); + for (String filename : possibleFiles) { + File customFile = new File(userBackgroundsFolder, filename); + if (customFile.exists() && customFile.isFile()) { + return new FileSystemResource(customFile); + } + } + } + String globalBackgroundsFolder = getBackgroundsFolder(); + for (String filename : possibleFiles) { + File customFile = new File(globalBackgroundsFolder, filename); + if (customFile.exists() && customFile.isFile()) { + return new FileSystemResource(customFile); + } + } + return new ClassPathResource("static/images/background.jpg"); + } + + // ======================================== + // UTILITY METHODS + // ======================================== + @Transactional public Optional checkForDuplicateAndUpdateMetadataIfNeeded(LibraryFile libraryFile, String hash, BookRepository bookRepository, BookAdditionalFileRepository bookAdditionalFileRepository, BookMapper bookMapper) { if (StringUtils.isBlank(hash)) { log.warn("Skipping file due to missing hash: {}", libraryFile.getFullPath()); return Optional.empty(); } + + // First check for soft-deleted books with the same hash + Optional softDeletedBook = bookRepository.findByCurrentHashAndDeletedTrue(hash); + if (softDeletedBook.isPresent()) { + BookEntity book = softDeletedBook.get(); + log.info("Found soft-deleted book with same hash, undeleting: bookId={} file='{}'", + book.getId(), libraryFile.getFileName()); + + // Undelete the book + book.setDeleted(false); + book.setDeletedAt(null); + + // Update file information + book.setFileName(libraryFile.getFileName()); + book.setFileSubPath(libraryFile.getFileSubPath()); + book.setLibraryPath(libraryFile.getLibraryPathEntity()); + book.setLibrary(libraryFile.getLibraryEntity()); + + return Optional.of(bookMapper.toBook(book)); + } + Optional existingByHash = bookRepository.findByCurrentHash(hash); if (existingByHash.isPresent()) { BookEntity book = existingByHash.get(); @@ -168,78 +397,4 @@ public class FileService { public static String truncate(String input, int maxLength) { return input == null ? null : (input.length() <= maxLength ? input : input.substring(0, maxLength)); } - - private void validateCoverFile(MultipartFile file) { - if (file.isEmpty()) { - throw new IllegalArgumentException("Uploaded file is empty"); - } - String contentType = file.getContentType(); - if (!(JPEG_MIME_TYPE.equalsIgnoreCase(contentType) || PNG_MIME_TYPE.equalsIgnoreCase(contentType))) { - throw new IllegalArgumentException("Only JPEG and PNG files are allowed"); - } - if (file.getSize() > MAX_FILE_SIZE_BYTES) { - throw new IllegalArgumentException("File size must not exceed 5 MB"); - } - } - - - public String getImagesFolder(long bookId) { - return appProperties.getPathConfig() + "/" + IMAGES_DIR + "/" + bookId + "/"; - } - - public String getThumbnailFile(long bookId) { - return appProperties.getPathConfig() + "/" + IMAGES_DIR + "/" + bookId + "/" + THUMBNAIL_FILENAME; - } - - public String getCoverFile(long bookId) { - return appProperties.getPathConfig() + "/" + IMAGES_DIR + "/" + bookId + "/" + COVER_FILENAME; - } - - public String getMetadataBackupPath() { - return appProperties.getPathConfig() + "/metadata_backup/"; - } - - public String getBookMetadataBackupPath(long bookId) { - return appProperties.getPathConfig() + "/metadata_backup/" + bookId + "/"; - } - - public String getCbxCachePath() { - return appProperties.getPathConfig() + "/cbx_cache"; - } - - public String getPdfCachePath() { - return appProperties.getPathConfig() + "/pdf_cache"; - } - - public String getTempBookdropCoverImagePath(long bookdropFileId) { - return Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFileId + ".jpg").toString(); - } - - public void createThumbnailFromUrl(long bookId, String imageUrl) { - try { - BufferedImage originalImage = downloadImageFromUrl(imageUrl); - boolean success = saveCoverImages(originalImage, bookId); - if (!success) { - throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); - } - log.info("Cover images created and saved from URL for book ID: {}", bookId); - } catch (Exception e) { - log.error("An error occurred while creating thumbnail from URL: {}", e.getMessage(), e); - throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); - } - } - - private BufferedImage downloadImageFromUrl(String imageUrl) throws IOException { - try { - URL url = new URL(imageUrl); - BufferedImage image = ImageIO.read(url); - if (image == null) { - throw new IOException("Unable to read image from URL: " + imageUrl); - } - return image; - } catch (Exception e) { - log.error("Failed to download image from URL: {} - {}", imageUrl, e.getMessage()); - throw new IOException("Failed to download image from URL: " + imageUrl, e); - } - } -} \ No newline at end of file +} diff --git a/booklore-api/src/main/resources/static/images/background.jpg b/booklore-api/src/main/resources/static/images/background.jpg new file mode 100644 index 000000000..5b66529b8 Binary files /dev/null and b/booklore-api/src/main/resources/static/images/background.jpg differ diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java b/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java index da827daff..ab0f324f6 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java @@ -7,7 +7,7 @@ import com.adityachandel.booklore.service.BookDownloadService; import com.adityachandel.booklore.service.BookQueryService; import com.adityachandel.booklore.service.BookService; import com.adityachandel.booklore.service.UserProgressService; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import com.adityachandel.booklore.util.FileService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -49,7 +49,7 @@ class BookServiceDeleteTests { BookQueryService bookQueryService = Mockito.mock(BookQueryService.class); UserProgressService userProgressService = Mockito.mock(UserProgressService.class); BookDownloadService bookDownloadService = Mockito.mock(BookDownloadService.class); - MonitoringProtectionService monitoringProtectionService = Mockito.mock(MonitoringProtectionService.class); + MonitoringRegistrationService monitoringRegistrationService = Mockito.mock(MonitoringRegistrationService.class); bookService = new BookService( bookRepository, @@ -66,7 +66,7 @@ class BookServiceDeleteTests { bookQueryService, userProgressService, bookDownloadService, - monitoringProtectionService + monitoringRegistrationService ); } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceMoveFilesTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceMoveFilesTest.java deleted file mode 100644 index cfe2d7ab1..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceMoveFilesTest.java +++ /dev/null @@ -1,792 +0,0 @@ -package com.adityachandel.booklore; - -import com.adityachandel.booklore.mapper.BookMapper; -import com.adityachandel.booklore.model.dto.Book; -import com.adityachandel.booklore.model.dto.request.FileMoveRequest; -import com.adityachandel.booklore.model.dto.settings.AppSettings; -import com.adityachandel.booklore.model.entity.*; -import com.adityachandel.booklore.model.enums.AdditionalFileType; -import com.adityachandel.booklore.model.websocket.Topic; -import com.adityachandel.booklore.repository.BookAdditionalFileRepository; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.service.BookQueryService; -import com.adityachandel.booklore.service.NotificationService; -import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.adityachandel.booklore.service.file.FileMoveService; -import com.adityachandel.booklore.service.library.LibraryService; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class FileMoveServiceMoveFilesTest { - - @Mock - private BookQueryService bookQueryService; - - @Mock - private BookRepository bookRepository; - - @Mock - private BookAdditionalFileRepository bookAdditionalFileRepository; - - @Mock - private BookMapper bookMapper; - - @Mock - private NotificationService notificationService; - - @Mock - private MonitoringProtectionService monitoringProtectionService; - - @Mock - private AppSettingService appSettingService; - - @Mock - private LibraryService libraryService; - - @InjectMocks - private FileMoveService fileMoveService; - - @TempDir - Path tempLibraryRoot; - - @BeforeEach - void setUp() { - // Configure the mock to actually execute the Runnable for all tests - doAnswer(invocation -> { - Runnable runnable = invocation.getArgument(0); - runnable.run(); - return null; - }).when(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("file move operations")); - } - - private BookEntity createBookWithFile(Path libraryRoot, String fileSubPath, String fileName) throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(42L) - .name("Test Library") - .fileNamingPattern(null) - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(libraryRoot.toString()) - .library(library) - .build(); - - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("Test Book") - .authors(new HashSet<>(List.of(new AuthorEntity(1L, "Author Name", new ArrayList<>()))) - ) - .publishedDate(LocalDate.of(2020, 1, 1)) - .build(); - - BookEntity book = BookEntity.builder() - .id(1L) - .fileName(fileName) - .fileSubPath(fileSubPath) - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - Path oldFilePath = book.getFullFilePath(); - Files.createDirectories(oldFilePath.getParent()); - Files.createFile(oldFilePath); - - return book; - } - - private BookAdditionalFileEntity createAdditionalFile(BookEntity book, Long id, String fileName, - String fileSubPath, AdditionalFileType type, - boolean createActualFile) throws IOException { - BookAdditionalFileEntity additionalFile = BookAdditionalFileEntity.builder() - .id(id) - .book(book) - .fileName(fileName) - .fileSubPath(fileSubPath) - .additionalFileType(type) - .build(); - - if (createActualFile) { - Path filePath = Paths.get(book.getLibraryPath().getPath()) - .resolve(fileSubPath) - .resolve(fileName); - Files.createDirectories(filePath.getParent()); - Files.createFile(filePath); - } - - return additionalFile; - } - - @Test - void testMoveFiles_skipsNonexistentFile() { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("NoFile") - .build(); - - LibraryEntity library = LibraryEntity.builder() - .id(43L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(2L) - .fileName("nofile.epub") - .fileSubPath("subfolder") - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(2L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(2L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("NoFile.epub"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("No file should be created for nonexistent source") - .isFalse(); - } - - @Test - void testMoveFiles_skipsBookWithoutLibraryPath() throws IOException { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("MissingLibrary") - .build(); - - BookEntity book = BookEntity.builder() - .id(3L) - .fileName("missinglibrary.epub") - .fileSubPath("subfolder") - .metadata(metadata) - .libraryPath(null) - .build(); - - Path fakeOldFile = tempLibraryRoot.resolve("subfolder").resolve("missinglibrary.epub"); - Files.createDirectories(fakeOldFile.getParent()); - Files.createFile(fakeOldFile); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(3L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(3L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("MissingLibrary.epub"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if library path is missing") - .isFalse(); - assertThat(Files.exists(fakeOldFile)) - .withFailMessage("Original file should remain when library path is missing") - .isTrue(); - - Files.deleteIfExists(fakeOldFile); - } - - @Test - void testMoveFiles_skipsBookWithNullFileName() throws IOException { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("NullFileName") - .build(); - - LibraryEntity library = LibraryEntity.builder() - .id(46L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(10L) - .fileName(null) - .fileSubPath("folder") - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(10L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(10L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("NullFileName"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if filename is null") - .isFalse(); - } - - @Test - void testMoveFiles_skipsBookWithEmptyFileName() throws IOException { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("EmptyFileName") - .build(); - - LibraryEntity library = LibraryEntity.builder() - .id(47L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(11L) - .fileName("") - .fileSubPath("folder") - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(11L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(11L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("EmptyFileName"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if filename is empty") - .isFalse(); - } - - @Test - void testMoveFiles_skipsBookWithNullFileSubPath() throws IOException { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("NullSubPath") - .build(); - - LibraryEntity library = LibraryEntity.builder() - .id(48L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(12L) - .fileName("file.epub") - .fileSubPath(null) - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - Path oldFile = tempLibraryRoot.resolve("file.epub"); - Files.createFile(oldFile); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(12L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(12L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("NullSubPath.epub"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if fileSubPath is null") - .isFalse(); - - Files.deleteIfExists(oldFile); - } - - @Test - void testMoveFiles_skipsBookWithNullMetadata() throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(49L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(13L) - .fileName("file.epub") - .fileSubPath("folder") - .metadata(null) - .libraryPath(libraryPathEntity) - .build(); - - Path oldFile = tempLibraryRoot.resolve("folder").resolve("file.epub"); - Files.createDirectories(oldFile.getParent()); - Files.createFile(oldFile); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(13L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(13L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve(".epub"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if metadata is null") - .isFalse(); - - Files.deleteIfExists(oldFile); - } - - @Test - void testMoveFiles_successfulMove() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - Path oldFilePath = book.getFullFilePath(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - Path newPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newPath)).isTrue(); - assertThat(Files.exists(oldFilePath)).isFalse(); - - verify(bookRepository).save(book); - verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), anyList()); - - // Verify monitoring protection was applied correctly - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("file move operations")); - - // Note: Library rescan now happens asynchronously with 8-second delay - // We can't verify it immediately in the test, but the operation is scheduled - } - - @Test - void testMoveFiles_usesDefaultPatternWhenLibraryPatternEmpty() throws IOException { - LibraryEntity lib = LibraryEntity.builder() - .id(99L).name("Lib").fileNamingPattern(" ").build(); - LibraryPathEntity lp = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()).library(lib).build(); - - BookMetadataEntity meta = BookMetadataEntity.builder() - .title("DFT").build(); - BookEntity book = BookEntity.builder() - .id(55L).fileSubPath("old").fileName("a.epub") - .libraryPath(lp).metadata(meta).build(); - - Path old = book.getFullFilePath(); - Files.createDirectories(old.getParent()); - Files.createFile(old); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(55L))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("DEF/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(55L)); - fileMoveService.moveFiles(req); - - Path moved = tempLibraryRoot.resolve("DEF").resolve("DFT.epub"); - assertThat(Files.exists(moved)).isTrue(); - verify(bookRepository).save(book); - } - - @Test - void testMoveFiles_deletesEmptyParentDirectories() throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(100L).name("Lib").fileNamingPattern(null).build(); - LibraryPathEntity lp = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()).library(library).build(); - BookMetadataEntity meta = BookMetadataEntity.builder() - .title("Nested").build(); - BookEntity book = BookEntity.builder() - .id(101L).fileSubPath("a/b/c").fileName("nested.epub") - .libraryPath(lp).metadata(meta).build(); - Path old = book.getFullFilePath(); - Files.createDirectories(old.getParent()); - Files.createFile(old); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(101L))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("Z/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - when(bookMapper.toBook(book)).thenReturn(Book.builder().id(book.getId()).build()); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(101L)); - fileMoveService.moveFiles(req); - - Path newPath = tempLibraryRoot.resolve("Z").resolve("Nested.epub"); - assertThat(Files.exists(newPath)).isTrue(); - assertThat(Files.notExists(tempLibraryRoot.resolve("a"))).isTrue(); - } - - @Test - void testMoveFiles_overwritesExistingDestination() throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(102L).name("Lib").fileNamingPattern(null).build(); - LibraryPathEntity lp = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()).library(library).build(); - BookMetadataEntity meta = BookMetadataEntity.builder() - .title("Overwrite").build(); - BookEntity book = BookEntity.builder() - .id(103L).fileSubPath("sub").fileName("file.epub") - .libraryPath(lp).metadata(meta).build(); - Path old = book.getFullFilePath(); - Files.createDirectories(old.getParent()); - Files.write(old, "SRC".getBytes()); - Path destDir = tempLibraryRoot.resolve("DEST"); - Files.createDirectories(destDir); - Path dest = destDir.resolve("Overwrite.epub"); - Files.write(dest, "OLD".getBytes()); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(103L))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("DEST/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - when(bookMapper.toBook(book)).thenReturn(Book.builder().id(book.getId()).build()); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(103L)); - fileMoveService.moveFiles(req); - - assertThat(Files.exists(dest)).isTrue(); - String content = Files.readString(dest); - assertThat(content).isEqualTo("SRC"); - } - - @Test - void testMoveFiles_usesLibraryPatternWhenSet() throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(104L).name("Lib").fileNamingPattern("LIBY/{title}").build(); - LibraryPathEntity lp = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()).library(library).build(); - BookMetadataEntity meta = BookMetadataEntity.builder() - .title("LibTest").build(); - BookEntity book = BookEntity.builder() - .id(105L).fileSubPath("x").fileName("file.epub") - .libraryPath(lp).metadata(meta).build(); - Path old = book.getFullFilePath(); - Files.createDirectories(old.getParent()); - Files.createFile(old); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(105L))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("DEFAULT/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - when(bookMapper.toBook(book)).thenReturn(Book.builder().id(book.getId()).build()); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(105L)); - fileMoveService.moveFiles(req); - - Path newPath = tempLibraryRoot.resolve("LIBY").resolve("LibTest.epub"); - assertThat(Files.exists(newPath)).isTrue(); - } - - @Test - void testMoveFiles_skipsWhenDestinationSameAsSource() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - Path oldFilePath = book.getFullFilePath(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))) - .thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("sub/mybook.epub"); - when(appSettingService.getAppSettings()).thenReturn(settings); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - assertThat(Files.exists(oldFilePath)).isTrue(); - verify(bookRepository, never()).save(any()); - verify(notificationService, never()).sendMessage(any(), anyList()); - verify(libraryService, never()).rescanLibrary(anyLong()); - } - - @Test - void testMoveFiles_executesWithMonitoringProtection() { - when(bookQueryService.findAllWithMetadataByIds(anySet())).thenReturn(List.of()); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(999L)); - fileMoveService.moveFiles(req); - - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("file move operations")); - } - - @Test - void testMoveFiles_movesAdditionalFiles() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - - // Create additional files - BookAdditionalFileEntity additionalFile1 = createAdditionalFile(book, 1L, "mybook.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - BookAdditionalFileEntity additionalFile2 = createAdditionalFile(book, 2L, "mybook_notes.txt", "sub", - AdditionalFileType.SUPPLEMENTARY, true); - - List additionalFiles = List.of(additionalFile1, additionalFile2); - book.setAdditionalFiles(additionalFiles); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify main book moved - Path newBookPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newBookPath)).isTrue(); - - // Verify additional files moved - Path newAdditionalPath1 = tempLibraryRoot.resolve("X").resolve("Test Book.pdf"); - Path newAdditionalPath2 = tempLibraryRoot.resolve("X").resolve("Test Book.txt"); - assertThat(Files.exists(newAdditionalPath1)).isTrue(); - assertThat(Files.exists(newAdditionalPath2)).isTrue(); - - // Verify database updated - verify(bookAdditionalFileRepository, times(2)).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_handlesUniqueNamesForAdditionalFiles() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - - // Create additional files that will result in same name after pattern resolution - BookAdditionalFileEntity additionalFile1 = createAdditionalFile(book, 1L, "version1.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - BookAdditionalFileEntity additionalFile2 = createAdditionalFile(book, 2L, "version2.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - - List additionalFiles = List.of(additionalFile1, additionalFile2); - book.setAdditionalFiles(additionalFiles); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify files moved with unique names - Path newAdditionalPath1 = tempLibraryRoot.resolve("X").resolve("Test Book.pdf"); - Path newAdditionalPath2 = tempLibraryRoot.resolve("X").resolve("Test Book_2.pdf"); - assertThat(Files.exists(newAdditionalPath1)).isTrue(); - assertThat(Files.exists(newAdditionalPath2)).isTrue(); - - verify(bookAdditionalFileRepository, times(2)).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_skipsNonexistentAdditionalFiles() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - - // Create additional file entity without actual file - BookAdditionalFileEntity additionalFile = createAdditionalFile(book, 1L, "nonexistent.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, false); - - book.setAdditionalFiles(List.of(additionalFile)); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify main book moved - Path newBookPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newBookPath)).isTrue(); - - // Verify nonexistent additional file not saved - verify(bookAdditionalFileRepository, never()).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_handlesEmptyAdditionalFilesList() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - book.setAdditionalFiles(new ArrayList<>()); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify main book moved - Path newBookPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newBookPath)).isTrue(); - - // Verify no additional file operations - verify(bookAdditionalFileRepository, never()).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_handlesNullAdditionalFilesList() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - book.setAdditionalFiles(null); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify main book moved - Path newBookPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newBookPath)).isTrue(); - - // Verify no additional file operations - verify(bookAdditionalFileRepository, never()).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_additionalFileWithDifferentExtensions() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - - // Create additional files with different extensions - BookAdditionalFileEntity additionalFile1 = createAdditionalFile(book, 1L, "mybook.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - BookAdditionalFileEntity additionalFile2 = createAdditionalFile(book, 2L, "mybook.mobi", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - BookAdditionalFileEntity additionalFile3 = createAdditionalFile(book, 3L, "cover.jpg", "sub", - AdditionalFileType.SUPPLEMENTARY, true); - - List additionalFiles = List.of(additionalFile1, additionalFile2, additionalFile3); - book.setAdditionalFiles(additionalFiles); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("Library/{authors}/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify all files moved with correct extensions - Path basePath = tempLibraryRoot.resolve("Library").resolve("Author Name"); - assertThat(Files.exists(basePath.resolve("Test Book.epub"))).isTrue(); - assertThat(Files.exists(basePath.resolve("Test Book.pdf"))).isTrue(); - assertThat(Files.exists(basePath.resolve("Test Book.mobi"))).isTrue(); - assertThat(Files.exists(basePath.resolve("Test Book.jpg"))).isTrue(); - - verify(bookAdditionalFileRepository, times(3)).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_skipsSamePathAdditionalFiles() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "X", "Test Book.epub"); - - // Create additional file that will resolve to same path - BookAdditionalFileEntity additionalFile = createAdditionalFile(book, 1L, "Test Book.pdf", "X", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - - book.setAdditionalFiles(List.of(additionalFile)); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify files still exist at original location - Path additionalFilePath = tempLibraryRoot.resolve("X").resolve("Test Book.pdf"); - assertThat(Files.exists(additionalFilePath)).isTrue(); - - // Verify no save called for additional file (skipped) - verify(bookAdditionalFileRepository, never()).save(any(BookAdditionalFileEntity.class)); - } -} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceTest.java index b09a3ae84..483fe90a1 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceTest.java @@ -90,7 +90,7 @@ class FileMoveServiceTest { .isIn( "/Good Omens - Neil Gaiman, Terry Pratchett (1990).mobi", "/Good Omens - Terry Pratchett, Neil Gaiman (1990).mobi" - ); // Set order is non-deterministic + ); } @Test @@ -162,7 +162,7 @@ class FileMoveServiceTest { BookEntity book = BookEntity.builder() .metadata(metadata) - .fileName("") // explicitly empty + .fileName("") .build(); String result = fileMoveService.generatePathFromPattern(book, "/{title}"); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java index f2c3769a3..14630cb88 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java @@ -1,29 +1,36 @@ 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.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.monitoring.MonitoringProtectionService; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; +import java.util.List; import java.util.Optional; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -36,7 +43,7 @@ class AdditionalFileServiceTest { private AdditionalFileMapper additionalFileMapper; @Mock - private MonitoringProtectionService monitoringProtectionService; + private MonitoringRegistrationService monitoringRegistrationService; @InjectMocks private AdditionalFileService additionalFileService; @@ -44,146 +51,243 @@ class AdditionalFileServiceTest { @TempDir Path tempDir; - @BeforeEach - void setUp() { - // Configure mock to execute the Runnable for file operations (lenient for tests that don't use it) - lenient().doAnswer(invocation -> { - Runnable runnable = invocation.getArgument(0); - runnable.run(); - return null; - }).when(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - } + private BookAdditionalFileEntity fileEntity; + private AdditionalFile additionalFile; + private BookEntity bookEntity; + private LibraryPathEntity libraryPathEntity; - @Test - void deleteAdditionalFile_success() throws IOException { - // Arrange + @BeforeEach + void setUp() throws IOException { Path testFile = tempDir.resolve("test-file.pdf"); Files.createFile(testFile); - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); + libraryPathEntity = new LibraryPathEntity(); + libraryPathEntity.setId(1L); + libraryPathEntity.setPath(tempDir.toString()); - BookEntity book = new BookEntity(); - book.setLibraryPath(libraryPath); + bookEntity = new BookEntity(); + bookEntity.setId(100L); + bookEntity.setLibraryPath(libraryPathEntity); - BookAdditionalFileEntity fileEntity = BookAdditionalFileEntity.builder() - .id(1L) - .fileName("test-file.pdf") - .fileSubPath("") - .additionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT) - .book(book) - .build(); + fileEntity = new BookAdditionalFileEntity(); + fileEntity.setId(1L); + fileEntity.setBook(bookEntity); + fileEntity.setFileName("test-file.pdf"); + fileEntity.setFileSubPath("."); + fileEntity.setAdditionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT); - when(additionalFileRepository.findById(1L)).thenReturn(Optional.of(fileEntity)); - - // Act - additionalFileService.deleteAdditionalFile(1L); - - // Assert - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - verify(additionalFileRepository).delete(fileEntity); - assertThat(Files.exists(testFile)).isFalse(); + additionalFile = mock(AdditionalFile.class); } @Test - void deleteAdditionalFile_fileNotFound() { - // Arrange - when(additionalFileRepository.findById(1L)).thenReturn(Optional.empty()); + void getAdditionalFilesByBookId_WhenFilesExist_ShouldReturnMappedFiles() { + Long bookId = 100L; + List entities = List.of(fileEntity); + List expectedFiles = List.of(additionalFile); - // Act & Assert - assertThatThrownBy(() -> additionalFileService.deleteAdditionalFile(1L)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Additional file not found with id: 1"); + when(additionalFileRepository.findByBookId(bookId)).thenReturn(entities); + when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - verify(monitoringProtectionService, never()).executeWithProtection(any(Runnable.class), any()); + List result = additionalFileService.getAdditionalFilesByBookId(bookId); + + assertEquals(expectedFiles, result); + verify(additionalFileRepository).findByBookId(bookId); + verify(additionalFileMapper).toAdditionalFiles(entities); } @Test - void deleteAdditionalFile_physicalFileNotExists() { - // Arrange - Path nonExistentFile = tempDir.resolve("non-existent.pdf"); + void getAdditionalFilesByBookId_WhenNoFilesExist_ShouldReturnEmptyList() { + Long bookId = 100L; + List entities = Collections.emptyList(); + List expectedFiles = Collections.emptyList(); - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); + when(additionalFileRepository.findByBookId(bookId)).thenReturn(entities); + when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - BookEntity book = new BookEntity(); - book.setLibraryPath(libraryPath); + List result = additionalFileService.getAdditionalFilesByBookId(bookId); - BookAdditionalFileEntity fileEntity = BookAdditionalFileEntity.builder() - .id(1L) - .fileName("non-existent.pdf") - .fileSubPath("") - .additionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT) - .book(book) - .build(); - - when(additionalFileRepository.findById(1L)).thenReturn(Optional.of(fileEntity)); - - // Act - additionalFileService.deleteAdditionalFile(1L); - - // Assert - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - verify(additionalFileRepository).delete(fileEntity); + assertTrue(result.isEmpty()); + verify(additionalFileRepository).findByBookId(bookId); + verify(additionalFileMapper).toAdditionalFiles(entities); } @Test - void deleteAdditionalFile_ioExceptionDuringDeletion() throws IOException { - // This test verifies that DB record is still deleted even if file deletion fails - // Arrange - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath("/invalid/path"); + void getAdditionalFilesByBookIdAndType_WhenFilesExist_ShouldReturnMappedFiles() { + Long bookId = 100L; + AdditionalFileType type = AdditionalFileType.ALTERNATIVE_FORMAT; + List entities = List.of(fileEntity); + List expectedFiles = List.of(additionalFile); - BookEntity book = new BookEntity(); - book.setLibraryPath(libraryPath); + when(additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type)).thenReturn(entities); + when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - BookAdditionalFileEntity fileEntity = BookAdditionalFileEntity.builder() - .id(1L) - .fileName("test-file.pdf") - .fileSubPath("") - .additionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT) - .book(book) - .build(); + List result = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type); - when(additionalFileRepository.findById(1L)).thenReturn(Optional.of(fileEntity)); - - // Act - additionalFileService.deleteAdditionalFile(1L); - - // Assert - DB record should still be deleted even if file deletion fails - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - verify(additionalFileRepository).delete(fileEntity); + assertEquals(expectedFiles, result); + verify(additionalFileRepository).findByBookIdAndAdditionalFileType(bookId, type); + verify(additionalFileMapper).toAdditionalFiles(entities); } @Test - void deleteAdditionalFile_withMonitoringProtection() { - // Arrange - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); + void getAdditionalFilesByBookIdAndType_WhenNoFilesExist_ShouldReturnEmptyList() { + Long bookId = 100L; + AdditionalFileType type = AdditionalFileType.SUPPLEMENTARY; + List entities = Collections.emptyList(); + List expectedFiles = Collections.emptyList(); - BookEntity book = new BookEntity(); - book.setLibraryPath(libraryPath); + when(additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type)).thenReturn(entities); + when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - BookAdditionalFileEntity fileEntity = BookAdditionalFileEntity.builder() - .id(1L) - .fileName("test-file.pdf") - .fileSubPath("subfolder") - .additionalFileType(AdditionalFileType.SUPPLEMENTARY) - .book(book) - .build(); + List result = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type); - when(additionalFileRepository.findById(1L)).thenReturn(Optional.of(fileEntity)); + assertTrue(result.isEmpty()); + verify(additionalFileRepository).findByBookIdAndAdditionalFileType(bookId, type); + verify(additionalFileMapper).toAdditionalFiles(entities); + } - // Act - additionalFileService.deleteAdditionalFile(1L); + @Test + void deleteAdditionalFile_WhenFileNotFound_ShouldThrowException() { + Long fileId = 1L; + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.empty()); - // Assert - Verify monitoring protection was used - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - - // Verify the operation name is correct for logging/tracking - verify(monitoringProtectionService).executeWithProtection( - any(Runnable.class), - eq("additional file deletion") + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> additionalFileService.deleteAdditionalFile(fileId) ); + + assertEquals("Additional file not found with id: 1", exception.getMessage()); + verify(additionalFileRepository).findById(fileId); + verify(additionalFileRepository, never()).delete(any()); + verify(monitoringRegistrationService, never()).unregisterSpecificPath(any()); } -} \ No newline at end of file + + @Test + void deleteAdditionalFile_WhenFileExists_ShouldDeleteSuccessfully() throws IOException { + Long fileId = 1L; + Path parentPath = fileEntity.getFullFilePath().getParent(); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(fileEntity)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.deleteIfExists(fileEntity.getFullFilePath())).thenReturn(true); + + additionalFileService.deleteAdditionalFile(fileId); + + verify(additionalFileRepository).findById(fileId); + verify(monitoringRegistrationService).unregisterSpecificPath(parentPath); + filesMock.verify(() -> Files.deleteIfExists(fileEntity.getFullFilePath())); + verify(additionalFileRepository).delete(fileEntity); + } + } + + @Test + void deleteAdditionalFile_WhenIOExceptionOccurs_ShouldStillDeleteFromRepository() throws IOException { + Long fileId = 1L; + Path parentPath = fileEntity.getFullFilePath().getParent(); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(fileEntity)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.deleteIfExists(fileEntity.getFullFilePath())).thenThrow(new IOException("File access error")); + + additionalFileService.deleteAdditionalFile(fileId); + + verify(additionalFileRepository).findById(fileId); + verify(monitoringRegistrationService).unregisterSpecificPath(parentPath); + filesMock.verify(() -> Files.deleteIfExists(fileEntity.getFullFilePath())); + verify(additionalFileRepository).delete(fileEntity); + } + } + + @Test + void deleteAdditionalFile_WhenEntityRelationshipsMissing_ShouldThrowIllegalStateException() { + Long fileId = 1L; + BookAdditionalFileEntity invalidEntity = new BookAdditionalFileEntity(); + invalidEntity.setId(fileId); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(invalidEntity)); + + assertThrows( + IllegalStateException.class, + () -> additionalFileService.deleteAdditionalFile(fileId) + ); + + verify(additionalFileRepository).findById(fileId); + verify(additionalFileRepository, never()).delete(any()); + verify(monitoringRegistrationService, never()).unregisterSpecificPath(any()); + } + + @Test + void downloadAdditionalFile_WhenFileNotFound_ShouldReturnNotFound() throws IOException { + Long fileId = 1L; + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.empty()); + + ResponseEntity result = additionalFileService.downloadAdditionalFile(fileId); + + assertEquals(HttpStatus.NOT_FOUND, result.getStatusCode()); + assertNull(result.getBody()); + verify(additionalFileRepository).findById(fileId); + } + + @Test + void downloadAdditionalFile_WhenPhysicalFileNotExists_ShouldReturnNotFound() throws IOException { + Long fileId = 1L; + + BookAdditionalFileEntity entityWithNonExistentFile = new BookAdditionalFileEntity(); + entityWithNonExistentFile.setId(fileId); + entityWithNonExistentFile.setBook(bookEntity); + entityWithNonExistentFile.setFileName("non-existent.pdf"); + entityWithNonExistentFile.setFileSubPath("."); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(entityWithNonExistentFile)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + Path actualPath = entityWithNonExistentFile.getFullFilePath(); + filesMock.when(() -> Files.exists(actualPath)).thenReturn(false); + + ResponseEntity result = additionalFileService.downloadAdditionalFile(fileId); + + assertEquals(HttpStatus.NOT_FOUND, result.getStatusCode()); + assertNull(result.getBody()); + verify(additionalFileRepository).findById(fileId); + filesMock.verify(() -> Files.exists(actualPath)); + } + } + + @Test + void downloadAdditionalFile_WhenFileExists_ShouldReturnFileResource() throws IOException { + Long fileId = 1L; + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(fileEntity)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(fileEntity.getFullFilePath())).thenReturn(true); + + ResponseEntity result = additionalFileService.downloadAdditionalFile(fileId); + + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertNotNull(result.getBody()); + assertTrue(result.getHeaders().containsKey(HttpHeaders.CONTENT_DISPOSITION)); + assertTrue(result.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION).contains("test-file.pdf")); + assertEquals(MediaType.APPLICATION_OCTET_STREAM, result.getHeaders().getContentType()); + + verify(additionalFileRepository).findById(fileId); + filesMock.verify(() -> Files.exists(fileEntity.getFullFilePath())); + } + } + + @Test + void downloadAdditionalFile_WhenEntityRelationshipsMissing_ShouldThrowIllegalStateException() throws IOException { + Long fileId = 1L; + BookAdditionalFileEntity invalidEntity = new BookAdditionalFileEntity(); + invalidEntity.setId(fileId); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(invalidEntity)); + + assertThrows( + IllegalStateException.class, + () -> additionalFileService.downloadAdditionalFile(fileId) + ); + + verify(additionalFileRepository).findById(fileId); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java deleted file mode 100644 index 127a8dc79..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java +++ /dev/null @@ -1,275 +0,0 @@ -package com.adityachandel.booklore.service; - -import com.adityachandel.booklore.config.security.service.AuthenticationService; -import com.adityachandel.booklore.mapper.BookMapper; -import com.adityachandel.booklore.model.dto.response.BookDeletionResponse; -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.*; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import com.adityachandel.booklore.util.FileService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Set; -import java.util.function.Supplier; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BookServiceDeleteBooksTest { - - @Mock private BookRepository bookRepository; - @Mock private PdfViewerPreferencesRepository pdfViewerPreferencesRepository; - @Mock private EpubViewerPreferencesRepository epubViewerPreferencesRepository; - @Mock private CbxViewerPreferencesRepository cbxViewerPreferencesRepository; - @Mock private NewPdfViewerPreferencesRepository newPdfViewerPreferencesRepository; - @Mock private ShelfRepository shelfRepository; - @Mock private FileService fileService; - @Mock private BookMapper bookMapper; - @Mock private UserRepository userRepository; - @Mock private UserBookProgressRepository userBookProgressRepository; - @Mock private AuthenticationService authenticationService; - @Mock private BookQueryService bookQueryService; - @Mock private UserProgressService userProgressService; - @Mock private BookDownloadService bookDownloadService; - @Mock private MonitoringProtectionService monitoringProtectionService; - - private BookService bookService; - - @TempDir - Path tempDir; - - @BeforeEach - void setUp() { - bookService = new BookService( - bookRepository, - pdfViewerPreferencesRepository, - epubViewerPreferencesRepository, - cbxViewerPreferencesRepository, - newPdfViewerPreferencesRepository, - shelfRepository, - fileService, - bookMapper, - userRepository, - userBookProgressRepository, - authenticationService, - bookQueryService, - userProgressService, - bookDownloadService, - monitoringProtectionService - ); - - // Configure mock to execute the Supplier for file operations - when(monitoringProtectionService.executeWithProtection(any(Supplier.class), eq("book deletion"))) - .thenAnswer(invocation -> { - Supplier supplier = invocation.getArgument(0); - return supplier.get(); - }); - } - - private LibraryEntity createTestLibrary() { - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); - - LibraryEntity library = new LibraryEntity(); - library.setId(1L); - library.setName("Test Library"); - library.setLibraryPaths(List.of(libraryPath)); - - return library; - } - - private LibraryPathEntity createTestLibraryPath() { - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); - return libraryPath; - } - - @Test - void deleteBooks_successfulDeletion() throws IOException { - // Arrange - Path testFile1 = tempDir.resolve("book1.epub"); - Path testFile2 = tempDir.resolve("book2.pdf"); - Files.createFile(testFile1); - Files.createFile(testFile2); - - LibraryEntity library = createTestLibrary(); - LibraryPathEntity libraryPath = createTestLibraryPath(); - - BookEntity book1 = BookEntity.builder() - .id(1L) - .fileName("book1.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - BookEntity book2 = BookEntity.builder() - .id(2L) - .fileName("book2.pdf") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - List books = List.of(book1, book2); - when(bookQueryService.findAllWithMetadataByIds(Set.of(1L, 2L))).thenReturn(books); - - // Act - ResponseEntity response = bookService.deleteBooks(Set.of(1L, 2L)); - - // Assert - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - BookDeletionResponse deletionResponse = response.getBody(); - assertThat(deletionResponse).isNotNull(); - assertThat(deletionResponse.getDeleted()).containsExactlyInAnyOrder(1L, 2L); - assertThat(deletionResponse.getFailedFileDeletions()).isEmpty(); - - // Verify monitoring protection was used - verify(monitoringProtectionService).executeWithProtection(any(Supplier.class), eq("book deletion")); - - // Verify books were deleted from database - verify(bookRepository).deleteAll(books); - - // Verify files were deleted - assertThat(Files.exists(testFile1)).isFalse(); - assertThat(Files.exists(testFile2)).isFalse(); - } - - @Test - void deleteBooks_fileNotFound() { - // Arrange - Path nonExistentFile = tempDir.resolve("non-existent.epub"); - - LibraryEntity library = createTestLibrary(); - LibraryPathEntity libraryPath = createTestLibraryPath(); - - BookEntity book = BookEntity.builder() - .id(1L) - .fileName("non-existent.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(1L))).thenReturn(List.of(book)); - - // Act - ResponseEntity response = bookService.deleteBooks(Set.of(1L)); - - // Assert - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - BookDeletionResponse deletionResponse = response.getBody(); - assertThat(deletionResponse).isNotNull(); - assertThat(deletionResponse.getDeleted()).containsExactly(1L); - - // File doesn't exist, so no deletion failure is recorded - assertThat(deletionResponse.getFailedFileDeletions()).isEmpty(); - - verify(monitoringProtectionService).executeWithProtection(any(Supplier.class), eq("book deletion")); - verify(bookRepository).deleteAll(List.of(book)); - } - - @Test - void deleteBooks_partialFailure() throws IOException { - // Arrange - Path existingFile = tempDir.resolve("existing.epub"); - Files.createFile(existingFile); - - LibraryEntity library = createTestLibrary(); - LibraryPathEntity libraryPath = createTestLibraryPath(); - - BookEntity existingBook = BookEntity.builder() - .id(1L) - .fileName("existing.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - BookEntity missingBook = BookEntity.builder() - .id(2L) - .fileName("missing.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - List books = List.of(existingBook, missingBook); - when(bookQueryService.findAllWithMetadataByIds(Set.of(1L, 2L))).thenReturn(books); - - // Act - ResponseEntity response = bookService.deleteBooks(Set.of(1L, 2L)); - - // Assert - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - BookDeletionResponse deletionResponse = response.getBody(); - assertThat(deletionResponse).isNotNull(); - assertThat(deletionResponse.getDeleted()).containsExactlyInAnyOrder(1L, 2L); - assertThat(deletionResponse.getFailedFileDeletions()).isEmpty(); // Missing file is not considered a failure - - verify(monitoringProtectionService).executeWithProtection(any(Supplier.class), eq("book deletion")); - verify(bookRepository).deleteAll(books); - - // Existing file should be deleted - assertThat(Files.exists(existingFile)).isFalse(); - } - - @Test - void deleteBooks_emptySet() { - // Act - ResponseEntity response = bookService.deleteBooks(Set.of()); - - // Assert - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - BookDeletionResponse deletionResponse = response.getBody(); - assertThat(deletionResponse).isNotNull(); - assertThat(deletionResponse.getDeleted()).isEmpty(); - assertThat(deletionResponse.getFailedFileDeletions()).isEmpty(); - - // Should still use monitoring protection even for empty operations - verify(monitoringProtectionService).executeWithProtection(any(Supplier.class), eq("book deletion")); - verify(bookRepository).deleteAll(List.of()); - } - - @Test - void deleteBooks_verifyMonitoringProtectionUsage() { - // Arrange - LibraryEntity library = createTestLibrary(); - LibraryPathEntity libraryPath = createTestLibraryPath(); - - BookEntity book = BookEntity.builder() - .id(1L) - .fileName("test.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(1L))).thenReturn(List.of(book)); - - // Act - bookService.deleteBooks(Set.of(1L)); - - // Assert - Verify monitoring protection is called with correct parameters - verify(monitoringProtectionService).executeWithProtection( - any(Supplier.class), - eq("book deletion") - ); - } -} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionConcurrentTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionConcurrentTest.java deleted file mode 100644 index 97c18c0f9..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionConcurrentTest.java +++ /dev/null @@ -1,276 +0,0 @@ -package com.adityachandel.booklore.service; - -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import com.adityachandel.booklore.service.monitoring.MonitoringService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class MonitoringProtectionConcurrentTest { - - @Mock - private MonitoringService monitoringService; - - private MonitoringProtectionService monitoringProtectionService; - - @BeforeEach - void setUp() { - monitoringProtectionService = new MonitoringProtectionService(monitoringService); - } - - @Test - void concurrentOperations_properSynchronization() throws InterruptedException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false, true, true, true, true); - - AtomicInteger operationCount = new AtomicInteger(0); - AtomicInteger pauseCount = new AtomicInteger(0); - AtomicInteger resumeCount = new AtomicInteger(0); - - // Track pause/resume calls - doAnswer(invocation -> { - pauseCount.incrementAndGet(); - return null; - }).when(monitoringService).pauseMonitoring(); - - doAnswer(invocation -> { - resumeCount.incrementAndGet(); - return null; - }).when(monitoringService).resumeMonitoring(); - - int numberOfThreads = 10; - ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); - CountDownLatch latch = new CountDownLatch(numberOfThreads); - - // Act - Run multiple concurrent operations - List> futures = new ArrayList<>(); - for (int i = 0; i < numberOfThreads; i++) { - final int operationId = i; - Future future = executor.submit(() -> { - try { - monitoringProtectionService.executeWithProtection(() -> { - operationCount.incrementAndGet(); - // Simulate some work - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }, "concurrent test operation " + operationId); - } finally { - latch.countDown(); - } - }); - futures.add(future); - } - - // Wait for all operations to complete - boolean completed = latch.await(30, TimeUnit.SECONDS); - executor.shutdown(); - - // Assert - assertThat(completed).isTrue(); - assertThat(operationCount.get()).isEqualTo(numberOfThreads); - - // Verify monitoring was paused at least once (may be fewer due to synchronization) - assertThat(pauseCount.get()).isGreaterThan(0); - - // Wait a bit for resume calls (they happen with delay in virtual threads) - Thread.sleep(6000); - assertThat(resumeCount.get()).isGreaterThan(0); - - // All futures should complete successfully - for (Future future : futures) { - assertThat(future.isDone()).isTrue(); - } - } - - @Test - void concurrentOperations_onlyOnePausesMonitoring() throws InterruptedException { - // Arrange - First call returns false (not paused), subsequent return true (already paused) - when(monitoringService.isPaused()).thenReturn(false).thenReturn(true); - - AtomicInteger pauseCallCount = new AtomicInteger(0); - doAnswer(invocation -> { - pauseCallCount.incrementAndGet(); - return null; - }).when(monitoringService).pauseMonitoring(); - - int numberOfThreads = 5; - ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch finishLatch = new CountDownLatch(numberOfThreads); - - // Act - Start all threads at the same time to maximize contention - for (int i = 0; i < numberOfThreads; i++) { - executor.submit(() -> { - try { - startLatch.await(); // Wait for signal to start - monitoringProtectionService.executeWithProtection(() -> { - // Simulate work - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }, "sync test"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - finishLatch.countDown(); - } - }); - } - - startLatch.countDown(); // Start all threads - boolean completed = finishLatch.await(10, TimeUnit.SECONDS); - executor.shutdown(); - - // Assert - assertThat(completed).isTrue(); - - // Only one thread should have actually called pauseMonitoring due to synchronization - assertThat(pauseCallCount.get()).isEqualTo(1); - } - - @Test - void concurrentOperations_withExceptions() throws InterruptedException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false, true, true); - - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger exceptionCount = new AtomicInteger(0); - AtomicInteger resumeCount = new AtomicInteger(0); - - doAnswer(invocation -> { - resumeCount.incrementAndGet(); - return null; - }).when(monitoringService).resumeMonitoring(); - - int numberOfThreads = 8; - ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); - CountDownLatch latch = new CountDownLatch(numberOfThreads); - - // Act - Half the operations throw exceptions - for (int i = 0; i < numberOfThreads; i++) { - final int operationId = i; - executor.submit(() -> { - try { - monitoringProtectionService.executeWithProtection(() -> { - if (operationId % 2 == 0) { - successCount.incrementAndGet(); - } else { - exceptionCount.incrementAndGet(); - throw new RuntimeException("Test exception " + operationId); - } - }, "exception test " + operationId); - } catch (RuntimeException e) { - // Expected for half the operations - } finally { - latch.countDown(); - } - }); - } - - boolean completed = latch.await(10, TimeUnit.SECONDS); - executor.shutdown(); - - // Assert - assertThat(completed).isTrue(); - assertThat(successCount.get()).isEqualTo(numberOfThreads / 2); - assertThat(exceptionCount.get()).isEqualTo(numberOfThreads / 2); - - // Wait for resume calls (delayed) - Thread.sleep(6000); - - // Even with exceptions, monitoring should still be resumed - assertThat(resumeCount.get()).isGreaterThan(0); - } - - @Test - void concurrentOperations_supplierReturnValues() throws InterruptedException, ExecutionException, TimeoutException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false, true, true, true); - - int numberOfThreads = 6; - ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); - - // Act - Use Supplier version to test return values - List> futures = new ArrayList<>(); - for (int i = 0; i < numberOfThreads; i++) { - final int operationId = i; - Future future = executor.submit(() -> - monitoringProtectionService.executeWithProtection(() -> { - try { - Thread.sleep(50); // Simulate work - return "Result-" + operationId; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return "Interrupted-" + operationId; - } - }, "supplier test " + operationId) - ); - futures.add(future); - } - - // Assert - List results = new ArrayList<>(); - for (Future future : futures) { - String result = future.get(10, TimeUnit.SECONDS); - results.add(result); - } - - executor.shutdown(); - - assertThat(results).hasSize(numberOfThreads); - for (int i = 0; i < numberOfThreads; i++) { - assertThat(results).contains("Result-" + i); - } - } - - @Test - void stressTest_manyQuickOperations() throws InterruptedException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false).thenReturn(true); - - AtomicInteger completedOperations = new AtomicInteger(0); - int numberOfOperations = 100; - ExecutorService executor = Executors.newFixedThreadPool(20); - CountDownLatch latch = new CountDownLatch(numberOfOperations); - - // Act - Many quick operations to test lock contention - for (int i = 0; i < numberOfOperations; i++) { - executor.submit(() -> { - try { - monitoringProtectionService.executeWithProtection(() -> { - completedOperations.incrementAndGet(); - // Very quick operation - }, "stress test"); - } finally { - latch.countDown(); - } - }); - } - - boolean completed = latch.await(30, TimeUnit.SECONDS); - executor.shutdown(); - - // Assert - assertThat(completed).isTrue(); - assertThat(completedOperations.get()).isEqualTo(numberOfOperations); - - // Should only pause once due to synchronization, even with many operations - verify(monitoringService, times(1)).pauseMonitoring(); - } -} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionIntegrationTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionIntegrationTest.java deleted file mode 100644 index c7b744ff1..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionIntegrationTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.adityachandel.booklore.service; - -import com.adityachandel.booklore.service.monitoring.MonitoringService; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.*; - -/** - * Integration test to verify that critical file operations properly use monitoring protection - * to prevent race conditions that can cause data loss. - * - * This addresses the race condition bug where monitoring would detect file operations - * as "missing files" and delete them before the operations completed. - */ -@ExtendWith(MockitoExtension.class) -class MonitoringProtectionIntegrationTest { - - @Mock - private MonitoringService monitoringService; - - @Test - void testMonitoringProtectionPattern_PauseAndResumeSequence() { - // Given - monitoring service that reports not paused initially - when(monitoringService.isPaused()).thenReturn(false); - - // When - simulating the monitoring protection pattern used in file operations - boolean didPause = pauseMonitoringIfNeeded(); - try { - // Critical file operation would happen here - // (simulated - actual file operations tested in integration tests) - } finally { - resumeMonitoringImmediately(didPause); - } - - // Then - verify the correct sequence occurred - verify(monitoringService).isPaused(); // Check current state - verify(monitoringService).pauseMonitoring(); // Pause before operation - verify(monitoringService).resumeMonitoring(); // Resume after operation - } - - @Test - void testMonitoringProtectionPattern_AlreadyPaused() { - // Given - monitoring service that reports already paused - when(monitoringService.isPaused()).thenReturn(true); - - // When - simulating the monitoring protection pattern - boolean didPause = pauseMonitoringIfNeeded(); - try { - // Critical file operation would happen here - } finally { - resumeMonitoringImmediately(didPause); - } - - // Then - verify no additional pause/resume calls were made - verify(monitoringService).isPaused(); // Check current state - verify(monitoringService, never()).pauseMonitoring(); // Should not pause again - verify(monitoringService, never()).resumeMonitoring(); // Should not resume what we didn't pause - } - - @Test - void testMonitoringProtectionPattern_ExceptionHandling() { - // Given - monitoring service that reports not paused initially - when(monitoringService.isPaused()).thenReturn(false); - - // When - simulating the monitoring protection pattern with exception - boolean didPause = pauseMonitoringIfNeeded(); - try { - // Simulate a critical file operation that throws an exception - throw new RuntimeException("File operation failed"); - } catch (RuntimeException e) { - // Exception handling would occur here in real code - } finally { - resumeMonitoringImmediately(didPause); - } - - // Then - verify monitoring was still resumed despite the exception - verify(monitoringService).isPaused(); - verify(monitoringService).pauseMonitoring(); - verify(monitoringService).resumeMonitoring(); // Critical: must resume even on failure - } - - // Helper methods that mirror the actual implementation pattern - - private boolean pauseMonitoringIfNeeded() { - if (!monitoringService.isPaused()) { - monitoringService.pauseMonitoring(); - return true; - } - return false; - } - - private void resumeMonitoringImmediately(boolean didPause) { - if (didPause) { - monitoringService.resumeMonitoring(); - } - } -} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionRaceConditionTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionRaceConditionTest.java deleted file mode 100644 index 82ae119df..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionRaceConditionTest.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.adityachandel.booklore.service; - -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import com.adityachandel.booklore.service.monitoring.MonitoringService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.io.IOException; -import java.nio.file.*; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -/** - * Integration test that simulates the actual race condition scenario: - * File operations happening while monitoring service detects "missing" files. - * - * This test verifies that MonitoringProtectionService prevents the race condition - * that was causing data loss in the original bug. - */ -@ExtendWith(MockitoExtension.class) -class MonitoringProtectionRaceConditionTest { - - @TempDir - Path tempDir; - - @Mock - private MonitoringService monitoringService; - - private MonitoringProtectionService monitoringProtectionService; - - @BeforeEach - void setUp() { - monitoringProtectionService = new MonitoringProtectionService(monitoringService); - } - - @Test - void raceConditionPrevention_fileMoveDuringMonitoring() throws InterruptedException, IOException, ExecutionException, TimeoutException { - // Arrange - Path sourceFile = tempDir.resolve("source.txt"); - Path targetFile = tempDir.resolve("target.txt"); - Files.write(sourceFile, "test content".getBytes()); - - AtomicBoolean fileOperationCompleted = new AtomicBoolean(false); - AtomicBoolean monitoringDetectedMissingFile = new AtomicBoolean(false); - AtomicBoolean raceConditionOccurred = new AtomicBoolean(false); - - // Configure mock behavior - monitoring is paused during file operations - when(monitoringService.isPaused()).thenAnswer(invocation -> { - // isPaused() should return true when monitoring is paused (during file operations) - // We start unpaused, then get paused during the operation, then unpaused after - return !fileOperationCompleted.get(); // true when operation is running = paused - }); - - // Start aggressive monitoring that looks for the file - ExecutorService monitoringExecutor = Executors.newSingleThreadExecutor(); - Future monitoringTask = monitoringExecutor.submit(() -> { - while (!fileOperationCompleted.get()) { - if (!monitoringService.isPaused() && !Files.exists(sourceFile)) { - monitoringDetectedMissingFile.set(true); - if (!fileOperationCompleted.get()) { - // This would be where the monitoring service deletes the "missing" file - raceConditionOccurred.set(true); - break; - } - } - try { - Thread.sleep(1); // Very aggressive checking - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - }); - - // Act - Perform file operation with protection - monitoringProtectionService.executeWithProtection(() -> { - try { - // Simulate the file operation that was causing issues - Thread.sleep(100); // Simulate some processing time - Files.move(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); - Thread.sleep(100); // Simulate more processing time - fileOperationCompleted.set(true); - } catch (InterruptedException | IOException e) { - throw new RuntimeException(e); - } - }, "race condition test"); - - // Wait for monitoring task to complete - monitoringTask.get(10, TimeUnit.SECONDS); - monitoringExecutor.shutdown(); - - // Assert - assertThat(Files.exists(targetFile)).isTrue(); - assertThat(Files.exists(sourceFile)).isFalse(); - assertThat(fileOperationCompleted.get()).isTrue(); - - // The critical assertion: monitoring should not have detected a missing file during the operation - assertThat(raceConditionOccurred.get()) - .withFailMessage("Race condition occurred! Monitoring detected missing file during protected operation") - .isFalse(); - } - - @Test - void raceConditionPrevention_multipleConcurrentFileOperations() throws InterruptedException, IOException, ExecutionException, TimeoutException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false).thenReturn(true); - - // Multiple file operations that could interfere with each other - int numberOfOperations = 10; - List sourceFiles = new ArrayList<>(); - List targetFiles = new ArrayList<>(); - - for (int i = 0; i < numberOfOperations; i++) { - Path source = tempDir.resolve("source_" + i + ".txt"); - Path target = tempDir.resolve("target_" + i + ".txt"); - Files.write(source, ("content " + i).getBytes()); - sourceFiles.add(source); - targetFiles.add(target); - } - - AtomicInteger completedOperations = new AtomicInteger(0); - AtomicInteger raceConditionCount = new AtomicInteger(0); - - // Start monitoring that checks for missing files - ExecutorService monitoringExecutor = Executors.newSingleThreadExecutor(); - Future monitoringTask = monitoringExecutor.submit(() -> { - while (completedOperations.get() < numberOfOperations) { - if (!monitoringService.isPaused()) { - // Check for any missing source files - for (int i = 0; i < numberOfOperations; i++) { - Path source = sourceFiles.get(i); - Path target = targetFiles.get(i); - - // If source is gone but target doesn't exist, it's a race condition - if (!Files.exists(source) && !Files.exists(target)) { - raceConditionCount.incrementAndGet(); - } - } - } - try { - Thread.sleep(1); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - }); - - // Act - Perform multiple concurrent file operations - ExecutorService operationExecutor = Executors.newFixedThreadPool(5); - CountDownLatch latch = new CountDownLatch(numberOfOperations); - - for (int i = 0; i < numberOfOperations; i++) { - final int operationIndex = i; - operationExecutor.submit(() -> { - try { - monitoringProtectionService.executeWithProtection(() -> { - try { - Thread.sleep(50); // Simulate processing - Files.move(sourceFiles.get(operationIndex), - targetFiles.get(operationIndex), - StandardCopyOption.REPLACE_EXISTING); - completedOperations.incrementAndGet(); - } catch (InterruptedException | IOException e) { - throw new RuntimeException(e); - } - }, "concurrent operation " + operationIndex); - } finally { - latch.countDown(); - } - }); - } - - // Wait for all operations to complete - boolean allCompleted = latch.await(30, TimeUnit.SECONDS); - monitoringTask.get(5, TimeUnit.SECONDS); - - operationExecutor.shutdown(); - monitoringExecutor.shutdown(); - - // Assert - assertThat(allCompleted).isTrue(); - assertThat(completedOperations.get()).isEqualTo(numberOfOperations); - - // All target files should exist - for (Path target : targetFiles) { - assertThat(Files.exists(target)).isTrue(); - } - - // No source files should exist - for (Path source : sourceFiles) { - assertThat(Files.exists(source)).isFalse(); - } - - // Critical assertion: no race conditions should have occurred - assertThat(raceConditionCount.get()) - .withFailMessage("Race conditions detected during concurrent operations") - .isEqualTo(0); - } - - @Test - void monitoringResumesAfterDelay() throws InterruptedException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false, true); - - // Act - Perform operation with protection - monitoringProtectionService.executeWithProtection(() -> { - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }, "delay test"); - - // Wait for monitoring to resume (should happen after 5 seconds) - Thread.sleep(6000); - - // Assert - Verify monitoring service methods were called - verify(monitoringService).pauseMonitoring(); - verify(monitoringService).resumeMonitoring(); - } -} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java new file mode 100644 index 000000000..afca34213 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java @@ -0,0 +1,421 @@ +package com.adityachandel.booklore.service.bookdrop; + +import com.adityachandel.booklore.config.AppProperties; +import com.adityachandel.booklore.exception.APIException; +import com.adityachandel.booklore.mapper.BookdropFileMapper; +import com.adityachandel.booklore.model.FileProcessResult; +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.BookdropFile; +import com.adityachandel.booklore.model.dto.BookdropFileNotification; +import com.adityachandel.booklore.model.dto.request.BookdropFinalizeRequest; +import com.adityachandel.booklore.model.dto.response.BookdropFinalizeResult; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +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.repository.BookRepository; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.repository.LibraryRepository; +import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.service.file.FileMovingHelper; +import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; +import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; +import com.adityachandel.booklore.service.metadata.MetadataRefreshService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Ignore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BookDropServiceTest { + + @Mock + private BookdropFileRepository bookdropFileRepository; + @Mock + private LibraryRepository libraryRepository; + @Mock + private BookRepository bookRepository; + @Mock + private BookdropMonitoringService bookdropMonitoringService; + @Mock + private NotificationService notificationService; + @Mock + private MetadataRefreshService metadataRefreshService; + @Mock + private BookdropNotificationService bookdropNotificationService; + @Mock + private BookFileProcessorRegistry processorRegistry; + @Mock + private AppProperties appProperties; + @Mock + private BookdropFileMapper mapper; + @Mock + private ObjectMapper objectMapper; + @Mock + private FileMovingHelper fileMovingHelper; + + @InjectMocks + private BookDropService bookDropService; + + @TempDir + Path tempDir; + + private BookdropFileEntity bookdropFileEntity; + private LibraryEntity libraryEntity; + private LibraryPathEntity libraryPathEntity; + private BookdropFile bookdropFile; + + @BeforeEach + void setUp() throws IOException { + libraryPathEntity = new LibraryPathEntity(); + libraryPathEntity.setId(1L); + libraryPathEntity.setPath(tempDir.toString()); + + libraryEntity = new LibraryEntity(); + libraryEntity.setId(1L); + libraryEntity.setName("Test Library"); + libraryEntity.setLibraryPaths(List.of(libraryPathEntity)); + + bookdropFileEntity = new BookdropFileEntity(); + bookdropFileEntity.setId(1L); + bookdropFileEntity.setFileName("test-book.pdf"); + bookdropFileEntity.setFilePath(tempDir.resolve("test-book.pdf").toString()); + bookdropFileEntity.setStatus(BookdropFileEntity.Status.PENDING_REVIEW); + bookdropFileEntity.setOriginalMetadata("{\"title\":\"Test Book\"}"); + bookdropFileEntity.setFetchedMetadata(null); + + bookdropFile = new BookdropFile(); + bookdropFile.setId(1L); + bookdropFile.setFileName("test-book.pdf"); + + Files.createFile(tempDir.resolve("test-book.pdf")); + } + + @Test + void getFileNotificationSummary_ShouldReturnCorrectCounts() { + when(bookdropFileRepository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW)).thenReturn(5L); + when(bookdropFileRepository.count()).thenReturn(10L); + + BookdropFileNotification result = bookDropService.getFileNotificationSummary(); + + assertEquals(5, result.getPendingCount()); + assertEquals(10, result.getTotalCount()); + assertNotNull(result.getLastUpdatedAt()); + verify(bookdropFileRepository).countByStatus(BookdropFileEntity.Status.PENDING_REVIEW); + verify(bookdropFileRepository).count(); + } + + @Test + void getFilesByStatus_WhenStatusIsPending_ShouldReturnPendingFiles() { + Pageable pageable = PageRequest.of(0, 10); + Page entityPage = new PageImpl<>(List.of(bookdropFileEntity)); + Page expectedPage = new PageImpl<>(List.of(bookdropFile)); + + when(bookdropFileRepository.findAllByStatus(BookdropFileEntity.Status.PENDING_REVIEW, pageable)) + .thenReturn(entityPage); + when(mapper.toDto(bookdropFileEntity)).thenReturn(bookdropFile); + + Page result = bookDropService.getFilesByStatus("pending", pageable); + + assertEquals(1, result.getContent().size()); + assertEquals(bookdropFile, result.getContent().get(0)); + verify(bookdropFileRepository).findAllByStatus(BookdropFileEntity.Status.PENDING_REVIEW, pageable); + verify(mapper).toDto(bookdropFileEntity); + } + + @Test + void getFilesByStatus_WhenStatusIsNotPending_ShouldReturnAllFiles() { + Pageable pageable = PageRequest.of(0, 10); + Page entityPage = new PageImpl<>(List.of(bookdropFileEntity)); + + when(bookdropFileRepository.findAll(pageable)).thenReturn(entityPage); + when(mapper.toDto(bookdropFileEntity)).thenReturn(bookdropFile); + + Page result = bookDropService.getFilesByStatus("all", pageable); + + assertEquals(1, result.getContent().size()); + verify(bookdropFileRepository).findAll(pageable); + verify(mapper).toDto(bookdropFileEntity); + } + + @Test + void getBookdropCover_WhenCoverExists_ShouldReturnResource() throws IOException { + long bookdropId = 1L; + when(appProperties.getPathConfig()).thenReturn(tempDir.toString()); + Path coverPath = tempDir.resolve("bookdrop_temp").resolve("1.jpg"); + Files.createDirectories(coverPath.getParent()); + Files.createFile(coverPath); + + Resource result = bookDropService.getBookdropCover(bookdropId); + + assertNotNull(result); + assertTrue(result.exists()); + } + + @Test + void getBookdropCover_WhenCoverDoesNotExist_ShouldReturnNull() { + long bookdropId = 999L; + when(appProperties.getPathConfig()).thenReturn(tempDir.toString()); + + Resource result = bookDropService.getBookdropCover(bookdropId); + + assertNull(result); + } + + @Test + void finalizeImport_ShouldPauseAndResumeMonitoring() { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(false); + request.setFiles(List.of()); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + assertNotNull(result); + assertNotNull(result.getProcessedAt()); + verify(bookdropMonitoringService).pauseMonitoring(); + verify(bookdropMonitoringService).resumeMonitoring(); + } + + @Test + @Disabled + void finalizeImport_WhenSelectAllTrue_ShouldProcessAllFiles() throws Exception { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(true); + request.setDefaultLibraryId(1L); + request.setDefaultPathId(1L); + request.setExcludedIds(List.of()); + + BookMetadata metadata = new BookMetadata(); + metadata.setTitle("Test Book"); + + when(bookdropFileRepository.findAllExcludingIdsFlat(any())).thenReturn(List.of(1L)); + when(bookdropFileRepository.findAllById(any())).thenReturn(List.of(bookdropFileEntity)); + when(libraryRepository.findById(1L)).thenReturn(Optional.of(libraryEntity)); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(metadata); + when(fileMovingHelper.getFileNamingPattern(libraryEntity)).thenReturn("{title}"); + when(fileMovingHelper.generateNewFilePath(anyString(), any(), anyString(), anyString())) + .thenReturn(tempDir.resolve("moved-book.pdf")); + + BookFileProcessor processor = mock(BookFileProcessor.class); + when(processorRegistry.getProcessorOrThrow(any())).thenReturn(processor); + + Book book = Book.builder() + .id(1L) + .title("Test Book") + .build(); + + FileProcessResult processResult = FileProcessResult.builder() + .book(book) + .build(); + when(processor.processFile(any())).thenReturn(processResult); + + BookEntity bookEntity = new BookEntity(); + bookEntity.setId(1L); + when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.createTempFile(anyString(), anyString())).thenReturn(tempDir.resolve("temp-file")); + filesMock.when(() -> Files.copy(any(Path.class), any(Path.class), any())).thenReturn(1024L); + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(tempDir); + filesMock.when(() -> Files.move(any(Path.class), any(Path.class), any())).thenReturn(tempDir); + filesMock.when(() -> Files.deleteIfExists(any(Path.class))).thenReturn(true); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + assertNotNull(result); + assertEquals(1, result.getTotalFiles()); + assertEquals(1, result.getSuccessfullyImported()); + assertEquals(0, result.getFailed()); + } + } + + @Test + void finalizeImport_WhenLibraryNotFound_ShouldFail() throws Exception { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(true); + request.setDefaultLibraryId(999L); + request.setDefaultPathId(1L); + + when(bookdropFileRepository.findAllExcludingIdsFlat(any())).thenReturn(List.of(1L)); + when(bookdropFileRepository.findAllById(any())).thenReturn(List.of(bookdropFileEntity)); + when(libraryRepository.findById(999L)).thenReturn(Optional.empty()); + + BookMetadata metadata = new BookMetadata(); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(metadata); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + assertNotNull(result); + assertEquals(1, result.getTotalFiles()); + assertEquals(0, result.getSuccessfullyImported()); + assertEquals(1, result.getFailed()); + } + } + + @Test + void discardSelectedFiles_WhenSelectAllTrue_ShouldDeleteAllExceptExcluded() throws IOException { + List excludedIds = List.of(2L); + BookdropFileEntity fileToDelete = new BookdropFileEntity(); + fileToDelete.setId(1L); + fileToDelete.setFilePath(tempDir.resolve("file-to-delete.pdf").toString()); + + Files.createFile(tempDir.resolve("file-to-delete.pdf")); + + when(bookdropFileRepository.findAll()).thenReturn(List.of(fileToDelete)); + when(appProperties.getBookdropFolder()).thenReturn(tempDir.toString()); + when(appProperties.getPathConfig()).thenReturn(tempDir.toString()); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.walk(any(Path.class))).thenReturn(java.util.stream.Stream.of(tempDir)); + filesMock.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); + filesMock.when(() -> Files.isRegularFile(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.deleteIfExists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.list(any(Path.class))).thenReturn(java.util.stream.Stream.empty()); + + bookDropService.discardSelectedFiles(true, excludedIds, null); + + verify(bookdropFileRepository).findAll(); + verify(bookdropFileRepository).deleteAllById(List.of(1L)); + verify(bookdropNotificationService).sendBookdropFileSummaryNotification(); + verify(bookdropMonitoringService).pauseMonitoring(); + verify(bookdropMonitoringService).resumeMonitoring(); + } + } + + @Test + void discardSelectedFiles_WhenSelectAllFalse_ShouldDeleteOnlySelected() throws IOException { + List selectedIds = List.of(1L); + when(bookdropFileRepository.findAllById(selectedIds)).thenReturn(List.of(bookdropFileEntity)); + when(appProperties.getBookdropFolder()).thenReturn(tempDir.toString()); + when(appProperties.getPathConfig()).thenReturn(tempDir.toString()); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.walk(any(Path.class))).thenReturn(java.util.stream.Stream.of(tempDir)); + filesMock.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); + filesMock.when(() -> Files.isRegularFile(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.deleteIfExists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.list(any(Path.class))).thenReturn(java.util.stream.Stream.empty()); + + bookDropService.discardSelectedFiles(false, null, selectedIds); + + verify(bookdropFileRepository).findAllById(selectedIds); + verify(bookdropFileRepository).deleteAllById(List.of(1L)); + verify(bookdropNotificationService).sendBookdropFileSummaryNotification(); + } + } + + @Test + void discardSelectedFiles_WhenBookdropFolderDoesNotExist_ShouldHandleGracefully() { + when(appProperties.getBookdropFolder()).thenReturn("/non-existent-path"); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(false); + + bookDropService.discardSelectedFiles(true, null, null); + + verify(bookdropMonitoringService).pauseMonitoring(); + verify(bookdropMonitoringService).resumeMonitoring(); + } + } + + @Test + void finalizeImport_WhenSourceFileDoesNotExist_ShouldDeleteFromDB() throws Exception { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(true); + request.setDefaultLibraryId(1L); + request.setDefaultPathId(1L); + + BookdropFileEntity missingFileEntity = new BookdropFileEntity(); + missingFileEntity.setId(2L); + missingFileEntity.setFileName("missing-file.pdf"); + missingFileEntity.setFilePath("/non-existent/missing-file.pdf"); + missingFileEntity.setOriginalMetadata("{\"title\":\"Missing Book\"}"); + missingFileEntity.setFetchedMetadata(null); + + when(bookdropFileRepository.findAllExcludingIdsFlat(any())).thenReturn(List.of(2L)); + when(bookdropFileRepository.findAllById(any())).thenReturn(List.of(missingFileEntity)); + when(libraryRepository.findById(1L)).thenReturn(Optional.of(libraryEntity)); + + BookMetadata metadata = new BookMetadata(); + metadata.setTitle("Missing Book"); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(metadata); + when(fileMovingHelper.getFileNamingPattern(libraryEntity)).thenReturn("{title}"); + when(fileMovingHelper.generateNewFilePath(anyString(), any(), anyString(), anyString())) + .thenReturn(tempDir.resolve("target-file.pdf")); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(Path.of("/non-existent/missing-file.pdf"))).thenReturn(false); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + verify(bookdropFileRepository).deleteById(2L); + verify(bookdropNotificationService).sendBookdropFileSummaryNotification(); + assertNotNull(result); + assertEquals(1, result.getTotalFiles()); + assertEquals(0, result.getSuccessfullyImported()); + assertEquals(1, result.getFailed()); + } + } + + @Test + void finalizeImport_WhenIOExceptionDuringMove_ShouldHandleGracefully() throws Exception { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(true); + request.setDefaultLibraryId(1L); + request.setDefaultPathId(1L); + + when(bookdropFileRepository.findAllExcludingIdsFlat(any())).thenReturn(List.of(1L)); + when(bookdropFileRepository.findAllById(any())).thenReturn(List.of(bookdropFileEntity)); + when(libraryRepository.findById(1L)).thenReturn(Optional.of(libraryEntity)); + + BookMetadata metadata = new BookMetadata(); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(metadata); + when(fileMovingHelper.getFileNamingPattern(libraryEntity)).thenReturn("{title}"); + when(fileMovingHelper.generateNewFilePath(anyString(), any(), anyString(), anyString())) + .thenReturn(tempDir.resolve("target-file.pdf")); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.createTempFile(anyString(), anyString())) + .thenThrow(new IOException("Disk full")); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + assertNotNull(result); + assertEquals(1, result.getTotalFiles()); + assertEquals(0, result.getSuccessfullyImported()); + assertEquals(1, result.getFailed()); + } + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java new file mode 100644 index 000000000..92ddbb11a --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java @@ -0,0 +1,362 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.dto.request.FileMoveRequest; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.service.BookQueryService; +import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.util.PathPatternResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FileMoveServiceTest { + + @Mock + private BookQueryService bookQueryService; + @Mock + private BookRepository bookRepository; + @Mock + private BookMapper bookMapper; + @Mock + private NotificationService notificationService; + @Mock + private UnifiedFileMoveService unifiedFileMoveService; + + @InjectMocks + private FileMoveService fileMoveService; + + private BookEntity bookEntity1; + private BookEntity bookEntity2; + private Book book1; + private Book book2; + + @BeforeEach + void setUp() { + // Setup BookMetadataEntity for book1 + BookMetadataEntity metadata1 = new BookMetadataEntity(); + metadata1.setTitle("Test Book 1"); + + bookEntity1 = new BookEntity(); + bookEntity1.setId(1L); + bookEntity1.setMetadata(metadata1); + metadata1.setBook(bookEntity1); + + // Setup BookMetadataEntity for book2 + BookMetadataEntity metadata2 = new BookMetadataEntity(); + metadata2.setTitle("Test Book 2"); + + bookEntity2 = new BookEntity(); + bookEntity2.setId(2L); + bookEntity2.setMetadata(metadata2); + metadata2.setBook(bookEntity2); + + book1 = mock(Book.class); + book2 = mock(Book.class); + } + + @Test + void moveFiles_WhenSingleBatch_ShouldProcessAllBooks() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + List batchBooks = List.of(bookEntity1, bookEntity2); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(batchBooks); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + .thenReturn(List.of()); + + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + when(bookMapper.toBook(bookEntity2)).thenReturn(book2); + + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoved(bookEntity1); + callback.onBookMoved(bookEntity2); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); + + // When + fileMoveService.moveFiles(request); + + // Then + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); + verify(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); + verify(bookRepository).save(bookEntity1); + verify(bookRepository).save(bookEntity2); + verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1, book2))); + } + + @Test + void moveFiles_WhenMultipleBatches_ShouldProcessAllBatches() { + // Given - create >100 ids so service iterates multiple batches + Set bookIds = IntStream.rangeClosed(1, 150) + .mapToObj(i -> (long) i) + .collect(Collectors.toSet()); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + // First batch + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of(bookEntity1)); + // Second batch + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + .thenReturn(List.of(bookEntity2)); + // Third batch - empty + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 200, 100)) + .thenReturn(List.of()); + + when(book1.getId()).thenReturn(1L); + when(book2.getId()).thenReturn(2L); + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + when(bookMapper.toBook(bookEntity2)).thenReturn(book2); + + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + List books = invocation.getArgument(0); + for (BookEntity book : books) { + callback.onBookMoved(book); + } + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + + // When + fileMoveService.moveFiles(request); + + // Then + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 100, 100); + verify(unifiedFileMoveService, times(2)).moveBatchBookFiles(any(), any()); + verify(bookRepository).save(bookEntity1); + verify(bookRepository).save(bookEntity2); + verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1, book2))); + } + + @Test + void moveFiles_WhenNoBooksFound_ShouldNotProcessAnything() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of()); + + // When + fileMoveService.moveFiles(request); + + // Then + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); + verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any()); + verify(bookRepository, never()).save(any()); + verify(notificationService, never()).sendMessage(any(), any()); + } + + @Test + void moveFiles_WhenMoveFailsForSomeBooks_ShouldThrowException() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + List batchBooks = List.of(bookEntity1, bookEntity2); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(batchBooks); + + RuntimeException moveException = new RuntimeException("File move failed"); + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoved(bookEntity1); + callback.onBookMoveFailed(bookEntity2, moveException); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); + + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + + // When & Then + RuntimeException exception = assertThrows(RuntimeException.class, () -> { + fileMoveService.moveFiles(request); + }); + + assertEquals("File move failed for book id 2", exception.getMessage()); + assertEquals(moveException, exception.getCause()); + verify(bookRepository).save(bookEntity1); + verify(bookRepository, never()).save(bookEntity2); + } + + @Test + void moveFiles_WhenEmptyBookIds_ShouldCompleteWithoutProcessing() { + // Given + Set bookIds = Set.of(); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + // When + fileMoveService.moveFiles(request); + + // Then: service should not call pagination when bookIds is empty + verify(bookQueryService, never()).findWithMetadataByIdsWithPagination(anySet(), anyInt(), anyInt()); + verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any()); + verify(notificationService, never()).sendMessage(any(), any()); + } + + @Test + void moveFiles_WhenPartialBatch_ShouldProcessCorrectly() { + // Given + Set bookIds = Set.of(1L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of(bookEntity1)); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + .thenReturn(List.of()); + + when(book1.getId()).thenReturn(1L); + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoved(bookEntity1); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + + // When + fileMoveService.moveFiles(request); + + // Then + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); + verify(unifiedFileMoveService).moveBatchBookFiles(eq(List.of(bookEntity1)), any()); + verify(bookRepository).save(bookEntity1); + verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1))); + } + + @Test + void generatePathFromPattern_ShouldDelegateToPathPatternResolver() { + // Given + String pattern = "{author}/{title}"; + String expectedPath = "John Doe/Test Book"; + + try (MockedStatic mockedResolver = mockStatic(PathPatternResolver.class)) { + mockedResolver.when(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern)) + .thenReturn(expectedPath); + + // When + String result = fileMoveService.generatePathFromPattern(bookEntity1, pattern); + + // Then + assertEquals(expectedPath, result); + mockedResolver.verify(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern)); + } + } + + @Test + void generatePathFromPattern_WithDifferentPatterns_ShouldReturnCorrectPaths() { + // Given + String pattern1 = "{title}"; + String pattern2 = "{author}/{series}/{title}"; + String expectedPath1 = "Test Book 1"; + String expectedPath2 = "Author/Series/Test Book 1"; + + try (MockedStatic mockedResolver = mockStatic(PathPatternResolver.class)) { + mockedResolver.when(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern1)) + .thenReturn(expectedPath1); + mockedResolver.when(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern2)) + .thenReturn(expectedPath2); + + // When + String result1 = fileMoveService.generatePathFromPattern(bookEntity1, pattern1); + String result2 = fileMoveService.generatePathFromPattern(bookEntity1, pattern2); + + // Then + assertEquals(expectedPath1, result1); + assertEquals(expectedPath2, result2); + mockedResolver.verify(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern1)); + mockedResolver.verify(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern2)); + } + } + + @Test + void moveFiles_ShouldSendNotificationWithAllUpdatedBooks() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of(bookEntity1, bookEntity2)); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + .thenReturn(List.of()); + + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + when(bookMapper.toBook(bookEntity2)).thenReturn(book2); + + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoved(bookEntity1); + callback.onBookMoved(bookEntity2); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + + // When + fileMoveService.moveFiles(request); + + // Then + ArgumentCaptor> booksCaptor = ArgumentCaptor.forClass(List.class); + verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), booksCaptor.capture()); + + List sentBooks = booksCaptor.getValue(); + assertEquals(2, sentBooks.size()); + assertTrue(sentBooks.contains(book1)); + assertTrue(sentBooks.contains(book2)); + } + + @Test + void moveFiles_WhenNoBooksMoved_ShouldNotSendNotification() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of(bookEntity1, bookEntity2)); + + RuntimeException moveException = new RuntimeException("All moves failed"); + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoveFailed(bookEntity1, moveException); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + + // When & Then + assertThrows(RuntimeException.class, () -> { + fileMoveService.moveFiles(request); + }); + + verify(notificationService, never()).sendMessage(any(), any()); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMovingHelperTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMovingHelperTest.java new file mode 100644 index 000000000..c781666f3 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMovingHelperTest.java @@ -0,0 +1,306 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.settings.AppSettings; +import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.util.PathPatternResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FileMovingHelperTest { + + @Mock + BookAdditionalFileRepository additionalFileRepository; + + @Mock + AppSettingService appSettingService; + + @InjectMocks + FileMovingHelper helper; + + @TempDir + Path tmp; + + LibraryEntity library; + LibraryPathEntity libraryPath; + BookEntity book; + + @BeforeEach + void init() { + library = new LibraryEntity(); + library.setId(11L); + library.setName("lib"); + libraryPath = new LibraryPathEntity(); + libraryPath.setId(22L); + libraryPath.setPath(tmp.toString()); + library.setLibraryPaths(List.of(libraryPath)); + + book = new BookEntity(); + book.setId(101L); + BookMetadataEntity metaEntity = new BookMetadataEntity(); + metaEntity.setTitle("Title"); + book.setMetadata(metaEntity); + metaEntity.setBook(book); + book.setLibraryPath(libraryPath); + } + + @Test + void generateNewFilePath_fromBook_usesResolvedPattern() { + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book), eq("{pattern}"))) + .thenReturn("some/sub/path/book.pdf"); + + Path result = helper.generateNewFilePath(book, "{pattern}"); + assertTrue(result.toAbsolutePath().toString().contains("some/sub/path/book.pdf")); + } + } + + @Test + void generateNewFilePath_fromMetadata_usesResolvedPattern() { + BookMetadata metadata = new BookMetadata(); + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(metadata), eq("{p}"), eq("orig.pdf"))) + .thenReturn("meta/path/orig.pdf"); + + Path result = helper.generateNewFilePath(tmp.toString(), metadata, "{p}", "orig.pdf"); + assertTrue(result.toString().endsWith("meta/path/orig.pdf")); + } + } + + @Test + void getFileNamingPattern_prefersLibrary_thenApp_thenFallback() { + library.setFileNamingPattern("LIB_PATTERN/{currentFilename}"); + String p1 = helper.getFileNamingPattern(library); + assertEquals("LIB_PATTERN/{currentFilename}", p1); + + library.setFileNamingPattern(null); + AppSettings settings = new AppSettings(); + settings.setUploadPattern("APP_PATTERN/{currentFilename}"); + when(appSettingService.getAppSettings()).thenReturn(settings); + String p2 = helper.getFileNamingPattern(library); + assertEquals("APP_PATTERN/{currentFilename}", p2); + + settings.setUploadPattern(null); + when(appSettingService.getAppSettings()).thenReturn(settings); + String p3 = helper.getFileNamingPattern(library); + assertEquals("{currentFilename}", p3); + } + + @Test + void hasRequiredPathComponents_returnsFalseWhenMissing() { + BookEntity b = new BookEntity(); + b.setId(1L); + b.setFileName("f"); + b.setFileSubPath("s"); + assertFalse(helper.hasRequiredPathComponents(b)); + + book.setFileSubPath("s"); + book.setFileName(null); + assertFalse(helper.hasRequiredPathComponents(book)); + + book.setFileName("file.pdf"); + assertTrue(helper.hasRequiredPathComponents(book)); + } + + @Test + void moveBookFileIfNeeded_noOpWhenPathsEqual() throws IOException { + book.setFileSubPath("same"); + book.setFileName("file.pdf"); + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book), anyString())) + .thenReturn("same/file.pdf"); + + boolean changed = helper.moveBookFileIfNeeded(book, "{p}"); + assertFalse(changed); + } + } + + @Test + void moveBookFileIfNeeded_movesAndUpdatesPaths() throws Exception { + book.setFileSubPath("olddir"); + book.setFileName("file.pdf"); + Path oldDir = tmp.resolve("olddir"); + Files.createDirectories(oldDir); + Path oldFile = oldDir.resolve("file.pdf"); + Files.createFile(oldFile); + + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book), anyString())) + .thenReturn("newdir/file.pdf"); + + boolean moved = helper.moveBookFileIfNeeded(book, "{p}"); + assertTrue(moved); + + Path newFile = tmp.resolve("newdir").resolve("file.pdf"); + assertTrue(Files.exists(newFile)); + assertEquals("newdir", book.getFileSubPath()); + assertEquals("file.pdf", book.getFileName()); + assertFalse(Files.exists(oldFile)); + } + } + + @Test + void moveAdditionalFiles_movesFilesAndSaves() throws Exception { + book.setFileSubPath("."); + book.setFileName("book.pdf"); + BookAdditionalFileEntity add = new BookAdditionalFileEntity(); + add.setId(555L); + add.setFileSubPath("oldsub"); + add.setFileName("add.pdf"); + add.setBook(book); + book.setAdditionalFiles(List.of(add)); + + Path oldDir = tmp.resolve("oldsub"); + Files.createDirectories(oldDir); + Path oldFile = oldDir.resolve("add.pdf"); + Files.createFile(oldFile); + + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book.getMetadata()), anyString(), eq("add.pdf"))) + .thenReturn("additional/newadd.pdf"); + + helper.moveAdditionalFiles(book, "{pattern}"); + + Path newFile = tmp.resolve("additional").resolve("newadd.pdf"); + assertTrue(Files.exists(newFile)); + verify(additionalFileRepository).save(add); + assertEquals("newadd.pdf", add.getFileName()); + } + } + + @Test + void generateNewFilePath_trimsLeadingSeparator() { + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book), eq("{pattern}"))) + .thenReturn("/leading/path/book.pdf"); + + Path result = helper.generateNewFilePath(book, "{pattern}"); + assertTrue(result.toString().endsWith("leading/path/book.pdf")); + assertFalse(result.toString().contains("//")); + } + } + + @Test + void getFileNamingPattern_appendsFilename_whenEndsWithSeparator() { + library.setFileNamingPattern("SOME/PATTERN/"); + String pattern = helper.getFileNamingPattern(library); + assertTrue(pattern.endsWith("{currentFilename}")); + assertEquals("SOME/PATTERN/{currentFilename}", pattern); + } + + @Test + void moveFile_createsParentDirectories_and_movesFile() throws Exception { + Path srcDir = tmp.resolve("srcdir"); + Files.createDirectories(srcDir); + Path src = srcDir.resolve("file.txt"); + Files.writeString(src, "hello"); + + Path target = tmp.resolve("nested").resolve("deep").resolve("file.txt"); + assertFalse(Files.exists(target.getParent())); + + helper.moveFile(src, target); + + assertTrue(Files.exists(target)); + assertFalse(Files.exists(src)); + assertTrue(Files.exists(target.getParent())); + } + + @Test + void deleteEmptyParentDirsUpToLibraryFolders_deletesIgnoredFilesAndDirs() throws Exception { + Path libRoot = tmp.resolve("libroot"); + Path dir1 = libRoot.resolve("dir1"); + Path dir2 = dir1.resolve("dir2"); + Files.createDirectories(dir2); + + Files.writeString(dir2.resolve(".DS_Store"), ""); + Files.writeString(dir1.resolve(".DS_Store"), ""); + + assertTrue(Files.exists(dir2)); + assertTrue(Files.exists(dir1)); + assertTrue(Files.exists(libRoot) || Files.createDirectories(libRoot) != null); + + helper.deleteEmptyParentDirsUpToLibraryFolders(dir2, Set.of(libRoot)); + + assertFalse(Files.exists(dir2)); + assertFalse(Files.exists(dir1)); + assertTrue(Files.exists(libRoot)); + } + + @Test + void deleteEmptyParentDirsUpToLibraryFolders_stopsWhenNonIgnoredPresent() throws Exception { + Path libRoot = tmp.resolve("libroot2"); + Path dir = libRoot.resolve("keepdir"); + Files.createDirectories(dir); + + Files.writeString(dir.resolve("keep.txt"), "keep"); + + helper.deleteEmptyParentDirsUpToLibraryFolders(dir, Set.of(libRoot)); + + assertTrue(Files.exists(dir)); + } + + @Test + void moveAdditionalFiles_handles_duplicate_target_names_and_saves() throws Exception { + book.setFileSubPath("."); + book.setFileName("book.pdf"); + + BookAdditionalFileEntity a1 = new BookAdditionalFileEntity(); + a1.setId(1L); + a1.setFileSubPath("oldsub"); + a1.setFileName("add.pdf"); + a1.setBook(book); + + BookAdditionalFileEntity a2 = new BookAdditionalFileEntity(); + a2.setId(2L); + a2.setFileSubPath("oldsub"); + a2.setFileName("add_2.pdf"); + a2.setBook(book); + + book.setAdditionalFiles(List.of(a1, a2)); + + Path oldDir = tmp.resolve("oldsub"); + Files.createDirectories(oldDir); + Files.writeString(oldDir.resolve("add.pdf"), "one"); + Files.writeString(oldDir.resolve("add_2.pdf"), "two"); + + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book.getMetadata()), anyString(), eq("add.pdf"))) + .thenReturn("additional/add.pdf"); + ms.when(() -> PathPatternResolver.resolvePattern(eq(book.getMetadata()), anyString(), eq("add_2.pdf"))) + .thenReturn("additional/add.pdf"); + + helper.moveAdditionalFiles(book, "{pattern}"); + + Path first = tmp.resolve("additional").resolve("add.pdf"); + Path second = tmp.resolve("additional").resolve("add_2.pdf"); + + assertTrue(Files.exists(first)); + assertTrue(Files.exists(second)); + + verify(additionalFileRepository, atLeastOnce()).save(any(BookAdditionalFileEntity.class)); + + assertTrue(a1.getFileName().equals("add.pdf") || a1.getFileName().equals("add_2.pdf")); + assertTrue(a2.getFileName().equals("add.pdf") || a2.getFileName().equals("add_2.pdf")); + assertNotEquals(a1.getFileName(), a2.getFileName()); + } + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/MonitoredFileOperationServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/MonitoredFileOperationServiceTest.java new file mode 100644 index 000000000..50b84ad15 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/MonitoredFileOperationServiceTest.java @@ -0,0 +1,208 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MonitoredFileOperationServiceTest { + + @Mock + MonitoringRegistrationService monitoringRegistrationService; + + @InjectMocks + MonitoredFileOperationService service; + + @TempDir + Path tmp; + + Path sourceDir; + Path targetDir; + Path sourceFile; + Path targetFile; + final Long libraryId = 42L; + + @BeforeEach + void init() throws IOException { + sourceDir = tmp.resolve("source"); + Files.createDirectories(sourceDir); + sourceFile = sourceDir.resolve("file.txt"); + Files.writeString(sourceFile, "data"); + + targetDir = tmp.resolve("target"); + targetFile = targetDir.resolve("file.txt"); + } + + @Test + void executeWithMonitoringSuspended_unregistersAndReregisters_whenDifferentPaths() { + Supplier operation = () -> { + try { + Files.createDirectories(targetDir); + Files.createDirectories(targetDir.resolve("sub")); + Files.writeString(targetFile, "moved"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "ok"; + }; + + when(monitoringRegistrationService.isPathMonitored(any())) + .thenAnswer(invocation -> { + Path p = invocation.getArgument(0); + return p.equals(sourceDir); + }); + + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation); + + verify(monitoringRegistrationService).isPathMonitored(eq(sourceDir)); + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService).registerSpecificPath(eq(sourceDir), eq(libraryId)); + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir), eq(libraryId)); + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir.resolve("sub")), eq(libraryId)); + } + + @Test + void executeWithMonitoringSuspended_skipsDoubleUnregister_whenSamePaths() { + Path src = sourceDir.resolve("a.txt"); + Path tgt = sourceDir.resolve("b.txt"); + + when(monitoringRegistrationService.isPathMonitored(eq(sourceDir))).thenReturn(true); + + Supplier operation = () -> { + try { + Files.writeString(tgt, "x"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return true; + }; + + service.executeWithMonitoringSuspended(src, tgt, libraryId, operation); + + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService, never()).unregisterSpecificPath(eq(targetDir)); + verify(monitoringRegistrationService).registerSpecificPath(eq(sourceDir), eq(libraryId)); + } + + @Test + void executeWithMonitoringSuspended_reRegistersEvenIfOperationThrows() { + when(monitoringRegistrationService.isPathMonitored(eq(sourceDir))).thenReturn(true); + + Supplier operation = () -> { + throw new IllegalStateException("boom"); + }; + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation) + ); + + assertEquals("boom", ex.getMessage()); + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService).registerSpecificPath(eq(sourceDir), eq(libraryId)); + } + + @Test + void reregister_handlesFilesWalkIOException_gracefully() throws Exception { + Supplier operation = () -> { + try { + Files.createDirectories(targetDir); + Files.writeString(targetFile, "moved"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "ok"; + }; + + when(monitoringRegistrationService.isPathMonitored(any())) + .thenAnswer(invocation -> false); + + try (MockedStatic filesMock = mockStatic(Files.class, invocation -> invocation.callRealMethod())) { + filesMock.when(() -> Files.exists(any(Path.class))).thenCallRealMethod(); + filesMock.when(() -> Files.isDirectory(any(Path.class))).thenCallRealMethod(); + filesMock.when(() -> Files.walk(eq(targetDir))).thenThrow(new IOException("walk fail")); + + assertDoesNotThrow(() -> + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation) + ); + + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir), eq(libraryId)); + } + } + + @Test + void executeWithMonitoringSuspended_skipsReregister_whenSourceRemovedByOperation() { + when(monitoringRegistrationService.isPathMonitored(eq(sourceDir))).thenReturn(true); + + Supplier operation = () -> { + try { + Files.deleteIfExists(sourceFile); + Files.deleteIfExists(sourceDir); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "done"; + }; + + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation); + + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService, never()).registerSpecificPath(eq(sourceDir), eq(libraryId)); + } + + @Test + void noUnregisters_whenNothingMonitored() { + Supplier operation = () -> { + try { + Files.createDirectories(targetDir); + Files.writeString(targetFile, "ok"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "ok"; + }; + + when(monitoringRegistrationService.isPathMonitored(any())).thenReturn(false); + + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation); + + verify(monitoringRegistrationService, never()).unregisterSpecificPath(any()); + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir), eq(libraryId)); + } + + @Test + void targetAlreadyMonitored_doesNotReRegister() throws Exception { + Files.createDirectories(targetDir); + when(monitoringRegistrationService.isPathMonitored(eq(sourceDir))).thenReturn(true); + when(monitoringRegistrationService.isPathMonitored(eq(targetDir))).thenReturn(true); + + Supplier operation = () -> { + try { + Files.writeString(targetFile, "x"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "done"; + }; + + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation); + + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService).unregisterSpecificPath(eq(targetDir)); + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir), eq(libraryId)); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java new file mode 100644 index 000000000..6a3a45fd7 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java @@ -0,0 +1,210 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.model.entity.LibraryPathEntity; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UnifiedFileMoveServiceTest { + + @Mock + FileMovingHelper fileMovingHelper; + + @Mock + MonitoredFileOperationService monitoredFileOperationService; + + @Mock + MonitoringRegistrationService monitoringRegistrationService; + + @InjectMocks + UnifiedFileMoveService service; + + @TempDir + Path tmp; + + LibraryEntity library; + LibraryPathEntity libraryPath; + + @BeforeEach + void setup() { + library = new LibraryEntity(); + library.setId(10L); + library.setName("lib"); + + libraryPath = new LibraryPathEntity(); + libraryPath.setId(20L); + libraryPath.setPath(tmp.toString()); + libraryPath.setLibrary(library); + + library.setLibraryPaths(singletonList(libraryPath)); + } + + @Test + void moveSingleBookFile_skipsWhenNoLibrary() { + BookEntity book = new BookEntity(); + // no libraryPath set + service.moveSingleBookFile(book); + verifyNoInteractions(monitoredFileOperationService); + verifyNoInteractions(fileMovingHelper); + } + + @Test + void moveSingleBookFile_skipsWhenFileMissing() throws Exception { + BookEntity book = new BookEntity(); + book.setId(1L); + book.setLibraryPath(libraryPath); + book.setFileSubPath("."); + book.setFileName("missing.pdf"); + + when(fileMovingHelper.getFileNamingPattern(any())).thenReturn("{currentFilename}"); + + service.moveSingleBookFile(book); + + verifyNoInteractions(monitoredFileOperationService); + } + + @Test + void moveSingleBookFile_executesMoveWhenNeeded() throws Exception { + BookEntity book = new BookEntity(); + book.setId(2L); + book.setLibraryPath(libraryPath); + book.setFileSubPath("."); + book.setFileName("book.pdf"); + + Path src = tmp.resolve("book.pdf"); + Files.writeString(src, "data"); + + Path expected = tmp.resolve("moved").resolve("book.pdf"); + + when(fileMovingHelper.getFileNamingPattern(library)).thenReturn("{currentFilename}"); + when(fileMovingHelper.generateNewFilePath(eq(book), anyString())).thenReturn(expected); + when(fileMovingHelper.hasRequiredPathComponents(eq(book))).thenReturn(true); + + doAnswer(invocation -> { + java.util.function.Supplier supplier = invocation.getArgument(3); + supplier.get(); + return null; + }).when(monitoredFileOperationService).executeWithMonitoringSuspended(any(), any(), anyLong(), any()); + + when(fileMovingHelper.moveBookFileIfNeeded(eq(book), anyString())).thenAnswer(inv -> { + book.setFileSubPath("moved"); + book.setFileName("book.pdf"); + return true; + }); + + Path actualSrc = book.getFullFilePath(); + + service.moveSingleBookFile(book); + + verify(monitoredFileOperationService).executeWithMonitoringSuspended(eq(actualSrc), eq(expected), eq(10L), any()); + verify(fileMovingHelper).moveBookFileIfNeeded(eq(book), anyString()); + assertEquals("moved", book.getFileSubPath()); + } + + @Test + void moveBatchBookFiles_movesBooks_and_callsCallback_and_reRegistersLibraries() throws Exception { + BookEntity b1 = new BookEntity(); + b1.setId(11L); + b1.setLibraryPath(libraryPath); + b1.setFileSubPath("."); + b1.setFileName("a.pdf"); + BookMetadataEntity m1 = new BookMetadataEntity(); + m1.setTitle("A"); + b1.setMetadata(m1); + m1.setBook(b1); + + BookEntity b2 = new BookEntity(); + b2.setId(12L); + b2.setLibraryPath(libraryPath); + b2.setFileSubPath("."); + b2.setFileName("b.pdf"); + BookMetadataEntity m2 = new BookMetadataEntity(); + m2.setTitle("B"); + b2.setMetadata(m2); + m2.setBook(b2); + + Path p1 = tmp.resolve("a.pdf"); + Path p2 = tmp.resolve("b.pdf"); + Files.writeString(p1, "1"); + Files.writeString(p2, "2"); + + when(fileMovingHelper.getFileNamingPattern(library)).thenReturn("{currentFilename}"); + when(fileMovingHelper.hasRequiredPathComponents(eq(b1))).thenReturn(true); + when(fileMovingHelper.hasRequiredPathComponents(eq(b2))).thenReturn(true); + + when(fileMovingHelper.moveBookFileIfNeeded(eq(b1), anyString())).thenAnswer(inv -> { + b1.setFileSubPath("moved"); + return true; + }); + when(fileMovingHelper.moveBookFileIfNeeded(eq(b2), anyString())).thenReturn(false); + + BookAdditionalFileEntity add = new BookAdditionalFileEntity(); + add.setId(100L); + add.setBook(b1); + add.setFileSubPath("."); + add.setFileName("extra.pdf"); + b1.setAdditionalFiles(List.of(add)); + doNothing().when(fileMovingHelper).moveAdditionalFiles(eq(b1), anyString()); + + UnifiedFileMoveService.BatchMoveCallback cb = mock(UnifiedFileMoveService.BatchMoveCallback.class); + + service.moveBatchBookFiles(List.of(b1, b2), cb); + + verify(monitoringRegistrationService).unregisterLibrary(eq(10L)); + verify(fileMovingHelper).moveBookFileIfNeeded(eq(b1), anyString()); + verify(fileMovingHelper).moveBookFileIfNeeded(eq(b2), anyString()); + verify(cb).onBookMoved(eq(b1)); + verify(cb, never()).onBookMoved(eq(b2)); + verify(fileMovingHelper).moveAdditionalFiles(eq(b1), anyString()); + verify(monitoringRegistrationService).registerLibraryPaths(eq(10L), any()); + } + + @Test + void moveBatchBookFiles_callsOnBookMoveFailed_onIOException() throws Exception { + BookEntity b = new BookEntity(); + b.setId(21L); + b.setLibraryPath(libraryPath); + b.setFileSubPath("."); + b.setFileName("c.pdf"); + BookMetadataEntity m = new BookMetadataEntity(); + m.setTitle("C"); + b.setMetadata(m); + m.setBook(b); + + Path p = tmp.resolve("c.pdf"); + Files.writeString(p, "c"); + + when(fileMovingHelper.getFileNamingPattern(library)).thenReturn("{currentFilename}"); + when(fileMovingHelper.hasRequiredPathComponents(eq(b))).thenReturn(true); + when(fileMovingHelper.moveBookFileIfNeeded(eq(b), anyString())).thenThrow(new IOException("disk")); + + UnifiedFileMoveService.BatchMoveCallback cb = mock(UnifiedFileMoveService.BatchMoveCallback.class); + + service.moveBatchBookFiles(List.of(b), cb); + + verify(cb).onBookMoveFailed(eq(b), any(IOException.class)); + verify(monitoringRegistrationService).unregisterLibrary(eq(10L)); + verify(monitoringRegistrationService).registerLibraryPaths(eq(10L), any()); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java new file mode 100644 index 000000000..05e5cda22 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java @@ -0,0 +1,153 @@ +package com.adityachandel.booklore.service.metadata.extractor; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class CbxMetadataExtractorTest { + + private CbxMetadataExtractor extractor; + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + extractor = new CbxMetadataExtractor(); + tempDir = Files.createTempDirectory("cbx_test_"); + } + + @AfterEach + void tearDown() throws IOException { + if (tempDir != null) { + // best-effort cleanup + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignore) {} }); + } + } + + @Test + void extractMetadata_fromCbz_withComicInfo_populatesFields() throws Exception { + String xml = "" + + "" + + " My Comic" + + " A short summary" + + " Indie" + + " Series X" + + " 2.5" + + " 12" + + " 2020714" + + " 42" + + " en" + + " Alice" + + " Bob" + + " action;adventure" + + ""; + + File cbz = createCbz("with_meta.cbz", new LinkedHashMap<>() {{ + put("ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8)); + put("page1.jpg", new byte[]{1,2,3}); + }}); + + BookMetadata md = extractor.extractMetadata(cbz); + assertEquals("My Comic", md.getTitle()); + assertEquals("A short summary", md.getDescription()); + assertEquals("Indie", md.getPublisher()); + assertEquals("Series X", md.getSeriesName()); + assertEquals(2.5f, md.getSeriesNumber()); + assertEquals(Integer.valueOf(12), md.getSeriesTotal()); + assertEquals(LocalDate.of(2020,7,14), md.getPublishedDate()); + assertEquals(Integer.valueOf(42), md.getPageCount()); + assertEquals("en", md.getLanguage()); + assertTrue(md.getAuthors().contains("Alice")); + assertTrue(md.getAuthors().contains("Bob")); + assertTrue(md.getCategories().contains("action")); + assertTrue(md.getCategories().contains("adventure")); + } + + @Test + void extractCover_fromCbz_usesComicInfoImageFile() throws Exception { + String xml = "" + + "" + + " " + + " " + + " " + + ""; + + byte[] img1 = new byte[]{11}; + byte[] img2 = new byte[]{22, 22}; // expect this one + byte[] img3 = new byte[]{33, 33, 33}; + + File cbz = createCbz("with_cover.cbz", new LinkedHashMap<>() {{ + put("ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8)); + put("images/001.jpg", img1); + put("images/002.jpg", img2); + put("images/003.jpg", img3); + }}); + + byte[] cover = extractor.extractCover(cbz); + assertArrayEquals(img2, cover); + } + + @Test + void extractCover_fromCbz_fallbackAlphabeticalFirst() throws Exception { + // No ComicInfo.xml, images intentionally added in unsorted order + byte[] aPng = new byte[]{1,1}; // alphabetically first (A.png) + byte[] bJpg = new byte[]{2}; + byte[] cJpeg = new byte[]{3,3,3}; + + File cbz = createCbz("fallback.cbz", new LinkedHashMap<>() {{ + put("z/pageC.jpeg", cJpeg); + put("A.png", aPng); // should be chosen + put("b.jpg", bJpg); + }}); + + byte[] cover = extractor.extractCover(cbz); + assertArrayEquals(aPng, cover); + } + + @Test + void extractMetadata_nonArchive_fallbackTitle() throws Exception { + Path txt = tempDir.resolve("Some Book Title.txt"); + Files.write(txt, "hello".getBytes(StandardCharsets.UTF_8)); + BookMetadata md = extractor.extractMetadata(txt.toFile()); + assertEquals("Some Book Title", md.getTitle()); + } + + // ---------- helpers ---------- + + private File createCbz(String name, Map entries) throws IOException { + Path out = tempDir.resolve(name); + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(out.toFile()))) { + for (Map.Entry e : entries.entrySet()) { + String entryName = e.getKey(); + byte[] data = e.getValue(); + ZipEntry ze = new ZipEntry(entryName); + // set a fixed time to avoid platform-dependent headers + ze.setTime(0L); + zos.putNextEntry(ze); + try (InputStream is = new ByteArrayInputStream(data)) { + is.transferTo(zos); + } + zos.closeEntry(); + } + } + return out.toFile(); + } +} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriterTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriterTest.java new file mode 100644 index 000000000..6979ce37f --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriterTest.java @@ -0,0 +1,203 @@ +package com.adityachandel.booklore.service.metadata.writer; + +import com.adityachandel.booklore.model.MetadataClearFlags; +import com.adityachandel.booklore.model.entity.AuthorEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.entity.CategoryEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class CbxMetadataWriterTest { + + private CbxMetadataWriter writer; + private Path tempDir; + + @BeforeEach + void setup() throws Exception { + writer = new CbxMetadataWriter(); + tempDir = Files.createTempDirectory("cbx_writer_test_"); + } + + @AfterEach + void cleanup() throws Exception { + if (tempDir != null) { + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignore) {} }); + } + } + + @Test + void getSupportedBookType_isCbx() { + assertEquals(BookFileType.CBX, writer.getSupportedBookType()); + } + + @Test + void writeMetadataToFile_cbz_updatesOrCreatesComicInfo_andPreservesOtherFiles() throws Exception { + // Create a CBZ without ComicInfo.xml and with a couple of images + File cbz = createCbz(tempDir.resolve("sample.cbz"), new String[]{ + "images/002.jpg", "images/001.jpg" + }); + + // Prepare metadata + BookMetadataEntity meta = new BookMetadataEntity(); + meta.setTitle("My Comic"); + meta.setDescription("Short desc"); + meta.setPublisher("Indie"); + meta.setSeriesName("Series X"); + meta.setSeriesNumber(2.5f); + meta.setSeriesTotal(12); + meta.setPublishedDate(LocalDate.of(2020,7,14)); + meta.setPageCount(42); + meta.setLanguage("en"); + + Set authors = new HashSet<>(); + AuthorEntity aliceAuthor = new AuthorEntity(); + aliceAuthor.setName("Alice"); + AuthorEntity bobAuthor = new AuthorEntity(); + bobAuthor.setName("Bob"); + authors.add(aliceAuthor); + authors.add(bobAuthor); + meta.setAuthors(authors); + Set cats = new HashSet<>(); + CategoryEntity actionCat = new CategoryEntity(); + actionCat.setName("action"); + CategoryEntity adventureCat = new CategoryEntity(); + adventureCat.setName("adventure"); + cats.add(actionCat); + cats.add(adventureCat); + meta.setCategories(cats); + + // Execute + writer.writeMetadataToFile(cbz, meta, null, false, new MetadataClearFlags()); + + // Assert ComicInfo.xml exists and contains our fields + try (ZipFile zip = new ZipFile(cbz)) { + ZipEntry ci = zip.getEntry("ComicInfo.xml"); + assertNotNull(ci, "ComicInfo.xml should be present after write"); + + Document doc = parseXml(zip.getInputStream(ci)); + String title = text(doc, "Title"); + String summary = text(doc, "Summary"); + String publisher = text(doc, "Publisher"); + String series = text(doc, "Series"); + String number = text(doc, "Number"); + String count = text(doc, "Count"); + String year = text(doc, "Year"); + String month = text(doc, "Month"); + String day = text(doc, "Day"); + String pageCount = text(doc, "PageCount"); + String lang = text(doc, "LanguageISO"); + String writerEl = text(doc, "Writer"); + String genre = text(doc, "Genre"); + + assertEquals("My Comic", title); + assertEquals("Short desc", summary); + assertEquals("Indie", publisher); + assertEquals("Series X", series); + assertEquals("2.5", number); + assertEquals("12", count); + assertEquals("2020", year); + assertEquals("7", month); + assertEquals("14", day); + assertEquals("42", pageCount); + assertEquals("en", lang); + if (writerEl != null) { + assertTrue(writerEl.contains("Alice")); + assertTrue(writerEl.contains("Bob")); + } + if (genre != null) { + assertTrue(genre.toLowerCase().contains("action")); + assertTrue(genre.toLowerCase().contains("adventure")); + } + + // Ensure original image entries are preserved + assertNotNull(zip.getEntry("images/001.jpg")); + assertNotNull(zip.getEntry("images/002.jpg")); + } + } + + @Test + void writeMetadataToFile_cbz_updatesExistingComicInfo() throws Exception { + // Create a CBZ *with* an existing ComicInfo.xml + Path out = tempDir.resolve("with_meta.cbz"); + String xml = "\n" + + " Old Title\n" + + " Old Summary\n" + + ""; + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(out.toFile()))) { + put(zos, "ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8)); + put(zos, "a.jpg", new byte[]{1}); + } + + BookMetadataEntity meta = new BookMetadataEntity(); + meta.setTitle("New Title"); + meta.setDescription("New Summary"); + + writer.writeMetadataToFile(out.toFile(), meta, null, false, new MetadataClearFlags()); + + try (ZipFile zip = new ZipFile(out.toFile())) { + ZipEntry ci = zip.getEntry("ComicInfo.xml"); + Document doc = parseXml(zip.getInputStream(ci)); + assertEquals("New Title", text(doc, "Title")); + assertEquals("New Summary", text(doc, "Summary")); + // a.jpg should still exist + assertNotNull(zip.getEntry("a.jpg")); + } + } + + // ------------- helpers ------------- + + private static File createCbz(Path path, String[] imageNames) throws Exception { + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(path.toFile()))) { + for (String name : imageNames) { + put(zos, name, new byte[]{1,2,3}); + } + } + return path.toFile(); + } + + private static void put(ZipOutputStream zos, String name, byte[] data) throws Exception { + ZipEntry ze = new ZipEntry(name); + ze.setTime(0L); + zos.putNextEntry(ze); + zos.write(data); + zos.closeEntry(); + } + + private static Document parseXml(InputStream is) throws Exception { + DocumentBuilderFactory f = DocumentBuilderFactory.newInstance(); + f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + f.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + f.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + DocumentBuilder b = f.newDocumentBuilder(); + return b.parse(is); + } + + private static String text(Document doc, String tag) { + var list = doc.getElementsByTagName(tag); + if (list.getLength() == 0) return null; + return list.item(0).getTextContent(); + } +} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationServiceTest.java new file mode 100644 index 000000000..6d4e30e45 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationServiceTest.java @@ -0,0 +1,140 @@ +package com.adityachandel.booklore.service.monitoring; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MonitoringRegistrationServiceTest { + + @Mock + MonitoringService monitoringService; + + @InjectMocks + MonitoringRegistrationService registrationService; + + @TempDir + Path tmp; + + Path root; + Path sub1; + Path sub2; + + @BeforeEach + void setupFs() throws IOException { + root = tmp.resolve("libroot"); + sub1 = root.resolve("a"); + sub2 = root.resolve("a").resolve("b"); + Files.createDirectories(sub2); + } + + @Test + void isPathMonitored_delegatesToMonitoringService() { + when(monitoringService.isPathMonitored(root)).thenReturn(true); + assertTrue(registrationService.isPathMonitored(root)); + verify(monitoringService).isPathMonitored(root); + } + + @Test + void unregisterSpecificPath_delegates() { + registrationService.unregisterSpecificPath(root); + verify(monitoringService).unregisterPath(root); + } + + @Test + void registerSpecificPath_delegates() { + registrationService.registerSpecificPath(root, 123L); + verify(monitoringService).registerPath(root, 123L); + } + + @Test + void unregisterLibrary_delegates() { + registrationService.unregisterLibrary(99L); + verify(monitoringService).unregisterLibrary(99L); + } + + @Test + void registerLibraryPaths_noopWhenMissingOrNotDirectory() throws IOException { + Path missing = tmp.resolve("does-not-exist"); + registrationService.registerLibraryPaths(7L, missing); + verifyNoInteractions(monitoringService); + + Path file = tmp.resolve("afile.txt"); + Files.writeString(file, "x"); + registrationService.registerLibraryPaths(7L, file); + verifyNoInteractions(monitoringService); + } + + @Test + void registerLibraryPaths_registersRootAndAllSubdirs() { + registrationService.registerLibraryPaths(42L, root); + + verify(monitoringService).registerPath(root, 42L); + + // subdirs a and a/b should be registered as well + verify(monitoringService).registerPath(sub1, 42L); + verify(monitoringService).registerPath(sub2, 42L); + + ArgumentCaptor capt = ArgumentCaptor.forClass(Path.class); + verify(monitoringService, atLeast(3)).registerPath(capt.capture(), eq(42L)); + List registered = capt.getAllValues(); + assertTrue(registered.contains(root)); + assertTrue(registered.contains(sub1)); + assertTrue(registered.contains(sub2)); + } + + @Test + void registerLibraryPaths_handlesMonitoringServiceExceptionGracefully() { + doThrow(new RuntimeException("boom")).when(monitoringService).registerPath(eq(root), eq(55L)); + assertDoesNotThrow(() -> registrationService.registerLibraryPaths(55L, root)); + verify(monitoringService).registerPath(root, 55L); + } + + @Test + void registerLibraryPaths_partialFailureStops() { + doAnswer(invocation -> { + Path p = invocation.getArgument(0); + Long id = invocation.getArgument(1); + if (id != null && id.equals(42L) && p.getFileName() != null && "a".equals(p.getFileName().toString())) { + throw new RuntimeException("fail-sub1"); + } + return null; + }).when(monitoringService).registerPath(any(Path.class), anyLong()); + + registrationService.registerLibraryPaths(42L, root); + + verify(monitoringService).registerPath(root, 42L); + verify(monitoringService).registerPath(argThat(p -> p.getFileName() != null && "a".equals(p.getFileName().toString())), eq(42L)); + verify(monitoringService, never()).registerPath(argThat(p -> p.getFileName() != null && "b".equals(p.getFileName().toString())), eq(42L)); + } + + @Test + void registerLibraryPaths_onlyRegistersDirectories() throws IOException { + Path fileInRoot = root.resolve("file.txt"); + Files.createDirectories(root); + Files.writeString(fileInRoot, "content"); + + registrationService.registerLibraryPaths(100L, root); + + verify(monitoringService).registerPath(root, 100L); + verify(monitoringService).registerPath(sub1, 100L); + verify(monitoringService).registerPath(sub2, 100L); + + verify(monitoringService, never()).registerPath(eq(fileInRoot), anyLong()); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringServiceTest.java new file mode 100644 index 000000000..4bdc09dce --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringServiceTest.java @@ -0,0 +1,288 @@ +package com.adityachandel.booklore.service.monitoring; + +import com.adityachandel.booklore.model.dto.Library; +import com.adityachandel.booklore.model.dto.LibraryPath; +import com.adityachandel.booklore.service.watcher.LibraryFileEventProcessor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class MonitoringServiceTest { + + @TempDir + Path tmp; + + MonitoringService service; + LibraryFileEventProcessor processor; + MonitoringTask monitoringTask; + WatchService watchService; + + @BeforeEach + void setup() throws Exception { + processor = mock(LibraryFileEventProcessor.class); + monitoringTask = mock(MonitoringTask.class); + watchService = FileSystems.getDefault().newWatchService(); + service = Mockito.spy(new MonitoringService(processor, watchService, monitoringTask)); + } + + @AfterEach + void teardown() throws Exception { + try { + service.stopMonitoring(); + } catch (Exception ignored) {} + try { watchService.close(); } catch (Exception ignored) {} + } + + @Test + void registerLibrary_registersAllDirectoriesUnderLibraryPath() throws Exception { + Path root = tmp.resolve("libroot"); + Path a = root.resolve("a"); + Path b = a.resolve("b"); + Files.createDirectories(b); + + Library lib = mock(Library.class); + LibraryPath lp = mock(LibraryPath.class); + when(lp.getPath()).thenReturn(root.toString()); + when(lib.getPaths()).thenReturn(List.of(lp)); + when(lib.getId()).thenReturn(7L); + when(lib.getName()).thenReturn("my-lib"); + when(lib.isWatch()).thenReturn(true); + + doReturn(true).when(service).registerPath(any(Path.class), eq(7L)); + + service.registerLibrary(lib); + + Files.walk(root).filter(Files::isDirectory).forEach(path -> + verify(service).registerPath(eq(path), eq(7L)) + ); + } + + @Test + void unregisterLibrary_removesRegisteredPathsAndUpdatesMaps() throws Exception { + Path root = tmp.resolve("libroot2"); + Files.createDirectories(root); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + map.put(root, 99L); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + monitored.add(root); + + Field registeredKeysField = MonitoringService.class.getDeclaredField("registeredWatchKeys"); + registeredKeysField.setAccessible(true); + @SuppressWarnings("unchecked") + Map keys = (Map) registeredKeysField.get(service); + WatchKey mockKey = mock(WatchKey.class); + keys.put(root, mockKey); + + service.unregisterLibrary(99L); + + assertFalse(monitored.contains(root), "monitoredPaths should no longer contain root"); + assertFalse(map.containsKey(root), "pathToLibraryIdMap should no longer contain root"); + assertFalse(keys.containsKey(root), "registeredWatchKeys should no longer contain root"); + verify(mockKey).cancel(); + } + + @Test + void handleFileChangeEvent_createDirectory_registersNestedPaths() throws Exception { + Path watched = tmp.resolve("watched"); + Files.createDirectories(watched); + Path newDir = watched.resolve("newdir"); + Files.createDirectories(newDir); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + map.put(watched, 5L); + + doReturn(true).when(service).registerPath(any(Path.class), eq(5L)); + + FileChangeEvent ev = mock(FileChangeEvent.class); + when(ev.getFilePath()).thenReturn(newDir); + doReturn(StandardWatchEventKinds.ENTRY_CREATE).when(ev).getEventKind(); + when(ev.getWatchedFolder()).thenReturn(watched); + + service.handleFileChangeEvent(ev); + + Files.walk(newDir).filter(Files::isDirectory).forEach(p -> verify(service).registerPath(eq(p), eq(5L))); + } + + @Test + void backgroundProcessor_processesQueuedEvents_and_callsProcessor() throws Exception { + Path watched = tmp.resolve("wf"); + Files.createDirectories(watched); + Path file = watched.resolve("book.pdf"); + Files.writeString(file, "x"); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + map.put(watched, 123L); + + java.lang.reflect.Method startMethod = MonitoringService.class.getDeclaredMethod("startProcessingThread"); + startMethod.setAccessible(true); + startMethod.invoke(service); + + FileChangeEvent ev = mock(FileChangeEvent.class); + when(ev.getFilePath()).thenReturn(file); + doReturn(StandardWatchEventKinds.ENTRY_CREATE).when(ev).getEventKind(); + when(ev.getWatchedFolder()).thenReturn(watched); + + service.handleFileChangeEvent(ev); + + verify(processor, timeout(2_000)).processFile(eq(StandardWatchEventKinds.ENTRY_CREATE), eq(123L), eq(watched.toString()), eq(file.toString())); + } + + @Test + void handleWatchKeyInvalidation_removesInvalidPath_and_cancelsKey() throws Exception { + Path invalid = tmp.resolve("inv"); + Files.createDirectories(invalid); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + monitored.add(invalid); + + Field registeredKeysField = MonitoringService.class.getDeclaredField("registeredWatchKeys"); + registeredKeysField.setAccessible(true); + @SuppressWarnings("unchecked") + Map keys = (Map) registeredKeysField.get(service); + WatchKey wk = mock(WatchKey.class); + keys.put(invalid, wk); + + WatchKeyInvalidatedEvent ev = mock(WatchKeyInvalidatedEvent.class); + when(ev.getInvalidPath()).thenReturn(invalid); + + service.handleWatchKeyInvalidation(ev); + + assertFalse(monitored.contains(invalid)); + assertFalse(keys.containsKey(invalid)); + verify(wk).cancel(); + } + + @Test + void isRelevantBookFile_detectsBookExtensions() { + Path pdf = Paths.get("somebook.pdf"); + Path txt = Paths.get("notes.txt"); + + assertTrue(service.isRelevantBookFile(pdf)); + assertFalse(service.isRelevantBookFile(txt)); + } + + @Test + void handleFileChangeEvent_ignoresIrrelevantNonBookFile() throws Exception { + Path watched = tmp.resolve("watched-ignore"); + Files.createDirectories(watched); + Path file = watched.resolve("notes.txt"); + Files.writeString(file, "notes"); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + map.put(watched, 11L); + + java.lang.reflect.Method startMethod = MonitoringService.class.getDeclaredMethod("startProcessingThread"); + startMethod.setAccessible(true); + startMethod.invoke(service); + + FileChangeEvent ev = mock(FileChangeEvent.class); + when(ev.getFilePath()).thenReturn(file); + doReturn(StandardWatchEventKinds.ENTRY_CREATE).when(ev).getEventKind(); + when(ev.getWatchedFolder()).thenReturn(watched); + + service.handleFileChangeEvent(ev); + + verify(processor, timeout(500).times(0)).processFile(any(), anyLong(), anyString(), anyString()); + } + + @Test + void handleFileChangeEvent_deleteDirectory_unregistersSubPaths() throws Exception { + Path watched = tmp.resolve("watched-del"); + Path a = watched.resolve("a"); + Path b = a.resolve("b"); + Files.createDirectories(b); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + monitored.add(watched); + monitored.add(a); + monitored.add(b); + + FileChangeEvent ev = mock(FileChangeEvent.class); + when(ev.getFilePath()).thenReturn(a); + doReturn(StandardWatchEventKinds.ENTRY_DELETE).when(ev).getEventKind(); + when(ev.getWatchedFolder()).thenReturn(watched); + + service.handleFileChangeEvent(ev); + + assertFalse(monitored.contains(a)); + assertFalse(monitored.contains(b)); + assertTrue(monitored.contains(watched)); + } + + @Test + void isPathMonitored_handlesNonNormalizedPaths() throws Exception { + Path root = tmp.resolve("libroot-norm"); + Path sub = root.resolve("subdir"); + Files.createDirectories(sub); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + monitored.add(sub.toAbsolutePath().normalize()); + + Path nonNormalized = root.resolve("subdir/../subdir/."); + assertTrue(service.isPathMonitored(nonNormalized)); + } + + @Test + void registerPath_successful_updatesInternalMapsAndSets() throws Exception { + Path dir = tmp.resolve("regdir"); + Files.createDirectories(dir); + + boolean registered = service.registerPath(dir, 55L); + assertTrue(registered); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + assertEquals(55L, map.get(dir)); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + assertTrue(monitored.contains(dir)); + + Field keysField = MonitoringService.class.getDeclaredField("registeredWatchKeys"); + keysField.setAccessible(true); + @SuppressWarnings("unchecked") + Map keys = (Map) keysField.get(service); + assertTrue(keys.containsKey(dir)); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java index d1eaafbe6..c394788d5 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java @@ -35,6 +35,10 @@ class OpdsServiceTest { @Mock private BookLoreUserTransformer bookLoreUserTransformer; @Mock + private com.adityachandel.booklore.service.library.LibraryService libraryService; + @Mock + private com.adityachandel.booklore.repository.ShelfRepository shelfRepository; + @Mock private HttpServletRequest request; @InjectMocks @@ -85,7 +89,7 @@ class OpdsServiceTest { } @Test - void generateCatalogFeed_opdsV2Admin_callsGetAllBooks_and_returnsV2Placeholder() { + void generateCatalogFeed_opdsV2Admin_callsGetAllBooks_and_returnsV2Json() { OpdsUserDetails details = mock(OpdsUserDetails.class); when(authenticationService.getOpdsUser()).thenReturn(details); when(details.getOpdsUser()).thenReturn(null); @@ -108,19 +112,21 @@ class OpdsServiceTest { // Accept header drives v2 selection; do not stub request.getRequestURI() (unused here) when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0"); - // stub the backend call exercised by getAllowedBooks for admin + no query - when(bookQueryService.getAllBooks(true)).thenReturn(List.of()); + // stub the backend call exercised by getAllowedBooksPage for admin + no query + when(bookQueryService.getAllBooksPage(true, 1, 50)).thenReturn(new org.springframework.data.domain.PageImpl<>(List.of())); String feed = service.generateCatalogFeed(request); - assertEquals("OPDS v2.0 Feed is under construction", feed); + assertNotNull(feed); + assertTrue(feed.trim().startsWith("{")); + assertTrue(feed.contains("\"publications\"")); verify(userRepository).findById(9L); - verify(bookQueryService).getAllBooks(true); + verify(bookQueryService).getAllBooksPage(true, 1, 50); } @Test - void generateSearchResults_opdsV2_callsSearch_and_returnsV2Placeholder() { + void generateSearchResults_opdsV2_callsSearch_and_returnsV2Json() { // Setup v2 user (admin) so getAllowedBooks will call searchBooksByMetadata(query) OpdsUserDetails details = mock(OpdsUserDetails.class); when(authenticationService.getOpdsUser()).thenReturn(details); @@ -143,14 +149,19 @@ class OpdsServiceTest { // Accept header drives v2 selection; do not stub request.getRequestURI() when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0"); - // getAllowedBooks will invoke searchBooksByMetadata for admin + query - when(bookQueryService.searchBooksByMetadata("query")).thenReturn(List.of(mock(Book.class))); + // Stub the request parameter "q" that generateSearchResults reads + when(request.getParameter("q")).thenReturn("query"); + + // getAllowedBooksPage will invoke searchBooksByMetadataPage for admin + query + when(bookQueryService.searchBooksByMetadataPage("query", 1, 50)).thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(mock(Book.class)))); String feed = service.generateSearchResults(request, "query"); - assertEquals("OPDS v2.0 Feed is under construction", feed); + assertNotNull(feed); + assertTrue(feed.trim().startsWith("{")); + assertTrue(feed.contains("\"publications\"")); verify(userRepository).findById(9L); - verify(bookQueryService).searchBooksByMetadata("query"); + verify(bookQueryService).searchBooksByMetadataPage("query", 1, 50); } @Test @@ -208,9 +219,79 @@ class OpdsServiceTest { } @Test - void generateSearchDescription_opdsV2_returnsV2Placeholder() { + void generateSearchDescription_opdsV2_returnsOpenSearchXml() { when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0"); String desc = service.generateSearchDescription(request); - assertEquals("OPDS v2.0 Feed is under construction", desc); + assertNotNull(desc); + assertTrue(desc.contains(" service.uploadFile(file, 1L, 1L)) @@ -206,22 +196,76 @@ class FileUploadServiceTest { path.setPath(tempDir.toString()); lib.setLibraryPaths(List.of(path)); when(libraryRepository.findById(7L)).thenReturn(Optional.of(lib)); + when(fileMovingHelper.getFileNamingPattern(lib)).thenReturn("{currentFilename}"); - BookFileProcessor proc = mock(BookFileProcessor.class); - FileProcessResult fileProcessResult = FileProcessResult.builder() - .book(Book.builder().build()) - .status(FileProcessStatus.NEW) - .build(); + service.uploadFile(file, 7L, 2L); - when(processorRegistry.getProcessorOrThrow(BookFileType.CBX)).thenReturn(proc); - when(proc.processFile(any())).thenReturn(fileProcessResult); - - Book result = service.uploadFile(file, 7L, 2L); - - assertThat(result).isSameAs(fileProcessResult.getBook()); Path moved = tempDir.resolve("book.cbz"); assertThat(Files.exists(moved)).isTrue(); - verify(notificationService).sendMessage(eq(Topic.BOOK_ADD), same(fileProcessResult.getBook())); verifyNoInteractions(pdfMetadataExtractor, epubMetadataExtractor); } + + @Test + void uploadAdditionalFile_successful_and_saves_entity() throws Exception { + long bookId = 5L; + MockMultipartFile file = new MockMultipartFile("file", "add.pdf", "application/pdf", "payload".getBytes()); + + LibraryPathEntity libPath = new LibraryPathEntity(); + libPath.setId(1L); + libPath.setPath(tempDir.toString()); + BookEntity book = new BookEntity(); + book.setId(bookId); + book.setLibraryPath(libPath); + book.setFileSubPath("."); + + when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + try (MockedStatic fp = mockStatic(FileFingerprint.class)) { + fp.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash-123"); + + when(bookAdditionalFileRepository.findByAltFormatCurrentHash("hash-123")).thenReturn(Optional.empty()); + + when(bookAdditionalFileRepository.save(any(BookAdditionalFileEntity.class))).thenAnswer(inv -> { + BookAdditionalFileEntity e = inv.getArgument(0); + e.setId(99L); + return e; + }); + + AdditionalFile dto = mock(AdditionalFile.class); + when(additionalFileMapper.toAdditionalFile(any(BookAdditionalFileEntity.class))).thenReturn(dto); + + AdditionalFile result = service.uploadAdditionalFile(bookId, file, AdditionalFileType.ALTERNATIVE_FORMAT, "desc"); + + assertThat(result).isEqualTo(dto); + verify(bookAdditionalFileRepository).save(any(BookAdditionalFileEntity.class)); + verify(additionalFileMapper).toAdditionalFile(any(BookAdditionalFileEntity.class)); + } + } + + @Test + void uploadAdditionalFile_duplicate_alternative_format_throws() { + long bookId = 6L; + MockMultipartFile file = new MockMultipartFile("file", "alt.pdf", "application/pdf", "payload".getBytes()); + + LibraryPathEntity libPath = new LibraryPathEntity(); + libPath.setId(2L); + libPath.setPath(tempDir.toString()); + BookEntity book = new BookEntity(); + book.setId(bookId); + book.setLibraryPath(libPath); + book.setFileSubPath("."); + + when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + try (MockedStatic fp = mockStatic(FileFingerprint.class)) { + fp.when(() -> FileFingerprint.generateHash(any())).thenReturn("dup-hash"); + + BookAdditionalFileEntity existing = new BookAdditionalFileEntity(); + existing.setId(1L); + when(bookAdditionalFileRepository.findByAltFormatCurrentHash("dup-hash")).thenReturn(Optional.of(existing)); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> service.uploadAdditionalFile(bookId, file, AdditionalFileType.ALTERNATIVE_FORMAT, null)); + } + } } diff --git a/booklore-ui/src/app/app.component.html b/booklore-ui/src/app/app.component.html index 4a654f4bb..f36225ead 100644 --- a/booklore-ui/src/app/app.component.html +++ b/booklore-ui/src/app/app.component.html @@ -1,14 +1,23 @@ @if (loading) {
- +

Loading Booklore…

Please wait while we get things ready.

} @else { - - - +
+
+
+ + + +
+
} diff --git a/booklore-ui/src/app/app.component.scss b/booklore-ui/src/app/app.component.scss index 397661ebe..04329c096 100644 --- a/booklore-ui/src/app/app.component.scss +++ b/booklore-ui/src/app/app.component.scss @@ -1,3 +1,25 @@ +.app-background { + min-height: 100vh; + position: relative; + background-size: cover; + background-position: center center; + background-attachment: fixed; + background-repeat: no-repeat; +} + +.app-overlay { + position: absolute; + inset: 0; + background: rgba(20, 20, 20, 0.4); + z-index: 0; +} + +.app-content { + position: relative; + z-index: 1; + min-height: 100vh; +} + .splash-screen { display: flex; align-items: center; diff --git a/booklore-ui/src/app/app.component.ts b/booklore-ui/src/app/app.component.ts index 849000bea..71aa844fd 100644 --- a/booklore-ui/src/app/app.component.ts +++ b/booklore-ui/src/app/app.component.ts @@ -1,4 +1,4 @@ -import {Component, inject, OnInit, OnDestroy} from '@angular/core'; +import {Component, computed, inject, OnDestroy, OnInit} from '@angular/core'; import {RxStompService} from './shared/websocket/rx-stomp.service'; import {BookService} from './book/service/book.service'; import {NotificationEventService} from './shared/websocket/notification-event.service'; @@ -37,6 +37,7 @@ export class AppComponent implements OnInit, OnDestroy { private taskEventService = inject(TaskEventService); private duplicateFileService = inject(DuplicateFileService); private appConfigService = inject(AppConfigService); // Keep it here to ensure the service is initialized + private readonly configService = inject(AppConfigService); ngOnInit(): void { this.authInit.initialized$.subscribe(ready => { @@ -101,4 +102,25 @@ export class AppComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.subscriptions.forEach(sub => sub.unsubscribe()); } + + readonly backgroundStyle = computed(() => { + const state = this.configService.appState(); + const backgroundImage = state.backgroundImage; + if (!backgroundImage) { + return 'none'; + } + + return `url('${backgroundImage}')`; + }); + + readonly blurStyle = computed(() => { + const state = this.configService.appState(); + const blur = state.backgroundBlur ?? AppConfigService.DEFAULT_BACKGROUND_BLUR; + return `blur(${blur}px)`; + }); + + readonly showBackground = computed(() => { + const state = this.configService.appState(); + return state.showBackground ?? true; + }); } diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index 714a68229..732e73ccf 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -17,6 +17,7 @@ import {OidcCallbackComponent} from './core/security/oidc-callback/oidc-callback import {CbxReaderComponent} from './book/components/cbx-reader/cbx-reader.component'; import {BookdropFileReviewComponent} from './bookdrop/bookdrop-file-review-component/bookdrop-file-review.component'; import {MainDashboardComponent} from './dashboard/components/main-dashboard/main-dashboard.component'; +import {SeriesPageComponent} from './book/components/series-page/series-page.component'; import {StatsComponent} from './stats-component/stats-component'; export const routes: Routes = [ @@ -42,6 +43,7 @@ export const routes: Routes = [ {path: 'library/:libraryId/books', component: BookBrowserComponent, canActivate: [AuthGuard]}, {path: 'shelf/:shelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard]}, {path: 'unshelved-books', component: BookBrowserComponent, canActivate: [AuthGuard]}, + {path: 'series/:seriesName', component: SeriesPageComponent, canActivate: [AuthGuard]}, { path: 'magic-shelf/:magicShelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard] }, {path: 'book/:bookId', component: BookMetadataCenterComponent, canActivate: [AuthGuard]}, {path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [AuthGuard]}, diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html index 782d24f78..c02649b23 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html @@ -277,15 +277,16 @@
- - + +
} diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss b/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss index 274a4e97e..ea261132d 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss @@ -1,6 +1,10 @@ .no-books-text { font-size: 1rem; + font-weight: 500; color: var(--text-color); + background: var(--card-background); + padding: 0.75rem 1rem; + border-radius: 0.5rem; } .no-books-container { diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts index 7a712172f..96b7ae332 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts @@ -147,7 +147,9 @@ export class BookBrowserComponent implements OnInit { private sideBarFilter = new SideBarFilter(this.selectedFilter, this.selectedFilterMode); private headerFilter = new HeaderFilter(this.searchTerm$); - protected bookSorter = new BookSorter(selectedSort => this.applySortOption(selectedSort)); + protected bookSorter = new BookSorter( + selectedSort => this.onManualSortChange(selectedSort) + ); @ViewChild(BookTableComponent) bookTableComponent!: BookTableComponent; @@ -215,7 +217,6 @@ export class BookBrowserComponent implements OnInit { ); this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks); - // --- NEW: Subscribe to query params + user changes for reactive updates --- combineLatest([ this.activatedRoute.paramMap, this.activatedRoute.queryParamMap, @@ -290,16 +291,17 @@ export class BookBrowserComponent implements OnInit { ? SortDirection.DESCENDING : SortDirection.ASCENDING; - const matchedSort = this.bookSorter.sortOptions.find(opt => opt.field === userSortKey) || this.bookSorter.sortOptions.find(opt => opt.field === sortParam); + const effectiveSortKey = sortParam || userSortKey; + const effectiveSortDir = directionParam + ? (directionParam.toLowerCase() === SORT_DIRECTION.DESCENDING ? SortDirection.DESCENDING : SortDirection.ASCENDING) + : userSortDir; + + const matchedSort = this.bookSorter.sortOptions.find(opt => opt.field === effectiveSortKey); this.bookSorter.selectedSort = matchedSort ? { label: matchedSort.label, field: matchedSort.field, - direction: userSortDir ?? ( - directionParam?.toUpperCase() === SORT_DIRECTION.DESCENDING - ? SortDirection.DESCENDING - : SortDirection.ASCENDING - ) + direction: effectiveSortDir } : { label: 'Added On', field: 'addedOn', @@ -453,6 +455,22 @@ export class BookBrowserComponent implements OnInit { this.seriesCollapseFilter.setCollapsed(value); } + onManualSortChange(sortOption: SortOption): void { + this.applySortOption(sortOption); + + const currentParams = this.activatedRoute.snapshot.queryParams; + const newParams = { + ...currentParams, + sort: sortOption.field, + direction: sortOption.direction === SortDirection.ASCENDING ? SORT_DIRECTION.ASCENDING : SORT_DIRECTION.DESCENDING + }; + + this.router.navigate([], { + queryParams: newParams, + replaceUrl: true + }); + } + applySortOption(sortOption: SortOption): void { if (this.entityType === EntityType.ALL_BOOKS) { this.bookState$ = this.fetchAllBooks(); @@ -673,7 +691,15 @@ export class BookBrowserComponent implements OnInit { const forceExpandSeries = this.shouldForceExpandSeries(); return this.headerFilter.filter(bookState).pipe( switchMap(filtered => this.sideBarFilter.filter(filtered)), - switchMap(filtered => this.seriesCollapseFilter.filter(filtered, forceExpandSeries)) + switchMap(filtered => this.seriesCollapseFilter.filter(filtered, forceExpandSeries)), + map(filtered => + (filtered.loaded && !filtered.error) + ? ({ + ...filtered, + books: this.sortService.applySort(filtered.books || [], this.bookSorter.selectedSort!) + }) + : filtered + ) ); } diff --git a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html index 3ad19c575..ef652da5c 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html @@ -14,24 +14,28 @@ [src]="urlHelper.getThumbnailUrl(book.id, book.metadata?.coverUpdatedOn)" class="book-cover" [class.loaded]="isImageLoaded" - alt="Cover of {{ book.metadata?.title }}" + alt="Cover of {{ displayTitle }}" loading="lazy" (load)="onImageLoad()"/> - @if (book.metadata?.seriesNumber != null) { + @if (!book.seriesCount && book.metadata?.seriesNumber != null) {
#{{ book.metadata?.seriesNumber }}
} - @if (book.seriesCount != null && book.seriesCount! > 1) { -
+ @if (book.seriesCount && book.seriesCount! >= 1) { +
{{ book.seriesCount }}
} - + @if (book.seriesCount && book.seriesCount! >= 1) { + + } @else { + + } @@ -70,8 +74,8 @@

- {{ book.metadata?.title }} + [pTooltip]="displayTitle"> + {{ displayTitle }}

@for (filter of filters; track trackByFilter(j, filter); let j = $index) {
- {{ filter.value.name || filter.value }} + +
}
diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.scss b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.scss index 551d25634..813bcdb3d 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.scss +++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.scss @@ -1,3 +1,11 @@ :host ::ng-deep p-accordion-header { --p-accordion-header-padding: 0.6rem 1rem; } + +.filter-row{ + align-items: flex-start; +} + +p-badge.filter-value-badge { + border-radius: 6px !important; /* Makes the badge square */ +} \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/book-browser/sorting/BookSorter.ts b/booklore-ui/src/app/book/components/book-browser/sorting/BookSorter.ts index ec7b432c6..91588009b 100644 --- a/booklore-ui/src/app/book/components/book-browser/sorting/BookSorter.ts +++ b/booklore-ui/src/app/book/components/book-browser/sorting/BookSorter.ts @@ -1,6 +1,5 @@ import {SortDirection, SortOption} from '../../../model/sort.model'; - export class BookSorter { selectedSort: SortOption | undefined = undefined; @@ -46,17 +45,6 @@ export class BookSorter { this.updateSortOptions(); this.applySortOption(this.selectedSort); - - /*this.router.navigate([], { - queryParams: { - sort: this.selectedSort.field, - direction: this.selectedSort.direction === SortDirection.ASCENDING - ? SORT_DIRECTION.ASCENDING - : SORT_DIRECTION.DESCENDING - }, - queryParamsHandling: 'merge', - replaceUrl: true - });*/ } updateSortOptions() { diff --git a/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.html b/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.html index 922ca6933..25f7218e2 100644 --- a/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.html +++ b/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.html @@ -5,13 +5,15 @@ alt="Cover of {{ book.metadata?.title }}" loading="lazy"/> - - + @if (!this.isActive) { + + + } diff --git a/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts b/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts index c0041670a..8bf6e9da8 100644 --- a/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts +++ b/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts @@ -22,6 +22,7 @@ import {TooltipModule} from 'primeng/tooltip'; }) export class BookCardLiteComponent implements OnInit, OnDestroy { @Input() book!: Book; + @Input() isActive: boolean = false; private router = inject(Router); protected urlHelper = inject(UrlHelperService); diff --git a/booklore-ui/src/app/book/components/series-page/series-page.component.html b/booklore-ui/src/app/book/components/series-page/series-page.component.html new file mode 100644 index 000000000..85902e5cc --- /dev/null +++ b/booklore-ui/src/app/book/components/series-page/series-page.component.html @@ -0,0 +1,123 @@ +@if (filteredBooks$ | async; as books) { +
+ + + + + Series Details + + + + + + @if (books[0]; as firstBook) { +
+
+ + +
+ +
+
+

+ {{ seriesTitle$ | async }} +

+
+ +

+ @for (author of firstBook.metadata?.authors; track $index; let isLast = $last) { + + {{ author }} + + @if (!isLast) { + , + } + } +

+ +
+ + @if (firstBook.metadata?.categories?.length) { +
+
+ @for (category of firstBook.metadata?.categories; track category) { + + + + } +
+
+ } +
+
+

+ Publisher: + @if (firstBook.metadata?.publisher; as publisher) { + + {{publisher}} + + } @else { + - + } +

+

Years: {{ (yearsRange$ | async) || '-' }}

+

Number of books: {{ books.length || 0}}

+

Language: {{ firstBook.metadata?.language || "-"}}

+

Read Status: + @let s = seriesReadStatus$ | async; + + {{ getStatusLabel(s) }} + +

+
+
+ + +
+
+
+
+ @let desc = firstDescription$ | async; + @if ((desc?.length ?? 0) > 500) { + + + } +
+ +
+
+ + +
+
No books found for this series.
+
+ +
+ +
+
+ } + +
+
+
+
+ +} @else { +
+ + +

+ Loading series details... +

+
+} \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/series-page/series-page.component.html.backup b/booklore-ui/src/app/book/components/series-page/series-page.component.html.backup new file mode 100644 index 000000000..a95b06536 --- /dev/null +++ b/booklore-ui/src/app/book/components/series-page/series-page.component.html.backup @@ -0,0 +1,147 @@ +@if (filteredBooks$ | async; as books) { +
+ + + + + Series Details + + + + + + + +
+
+ + +
+ + +
+
+

+ {{ seriesTitle$ | async }} +

+
+ +

+ @for (author of books[0]?.metadata?.authors; track $index; let isLast = $last) { + + + {{ author }} + + @if (!isLast) { + , + } + } +

+ +
+ + + @if (books[0]?.metadata?.categories?.length) { +
+
+ @for (category of books[0].metadata!.categories; track category) { + + + + } +
+
+ } + + +
+
+

Number of books: {{ books.length || 0}}

+

Publisher: {{ books[0]?.metadata?.publisher || "-"}}

+

Year: {{ books[0]?.metadata?.publishedDate || "-"}}

+

Language: {{ books[0]?.metadata?.language || "-"}}

+
+
+ + + +
+
+
+ +
+
+ +
+ + + + + + +
+
+ + +
+
No books found for this series.
+
+ +
+ +
+
+ + + +
+
+
+
+} @else { +
+ + +

+ Loading series details... +

+
+} \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/series-page/series-page.component.scss b/booklore-ui/src/app/book/components/series-page/series-page.component.scss new file mode 100644 index 000000000..0552f4ba6 --- /dev/null +++ b/booklore-ui/src/app/book/components/series-page/series-page.component.scss @@ -0,0 +1,18 @@ +.tabpanels-responsive { + height: calc(100dvh - 9.7rem); +} + +@media (max-width: 768px) { + .tabpanels-responsive { + height: calc(100dvh - 8.5rem); + } +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(1px, 1fr)); + gap: 1.3rem; + align-items: start; + width: 100%; +} + diff --git a/booklore-ui/src/app/book/components/series-page/series-page.component.ts b/booklore-ui/src/app/book/components/series-page/series-page.component.ts new file mode 100644 index 000000000..dbca831d4 --- /dev/null +++ b/booklore-ui/src/app/book/components/series-page/series-page.component.ts @@ -0,0 +1,226 @@ +import { Component, inject, ViewChild } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { Button } from "primeng/button"; +import { ActivatedRoute } from "@angular/router"; +import { AsyncPipe, NgClass, NgFor, NgIf, NgStyle } from "@angular/common"; +import { map, filter, switchMap } from "rxjs/operators"; +import { Observable, combineLatest } from "rxjs"; +import { Book, ReadStatus } from "../../model/book.model"; +import { BookService } from "../../service/book.service"; +import { BookCardComponent } from "../book-browser/book-card/book-card.component"; +import { CoverScalePreferenceService } from "../book-browser/cover-scale-preference.service"; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from "primeng/tabs"; +import { Tag } from "primeng/tag"; +import { VirtualScrollerModule } from "@iharbeck/ngx-virtual-scroller"; +import { ProgressSpinner } from "primeng/progressspinner"; +import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-series-page", + standalone: true, + templateUrl: "./series-page.component.html", + styleUrls: ["./series-page.component.scss"], + imports: [ + AsyncPipe, + Button, + FormsModule, + NgIf, + NgFor, + NgStyle, + NgClass, + BookCardComponent, + ProgressSpinner, + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + Tag, + + VirtualScrollerModule, + ], +}) +export class SeriesPageComponent { + + private route = inject(ActivatedRoute); + private bookService = inject(BookService); + protected coverScalePreferenceService = inject(CoverScalePreferenceService); + private metadataCenterViewMode: "route" | "dialog" = "route"; + private dialogRef?: DynamicDialogRef; + private router = inject(Router); + + tab: string = "view"; + isExpanded = false; + + + seriesParam$: Observable = this.route.paramMap.pipe( + map((params) => params.get("seriesName") || ""), + map((name) => decodeURIComponent(name)) + ); + + booksInSeries$: Observable = this.bookService.bookState$.pipe( + filter((state) => state.loaded && !!state.books), + map((state) => state.books || []) + ); + + filteredBooks$: Observable = combineLatest([ + this.seriesParam$.pipe(map((n) => n.trim().toLowerCase())), + this.booksInSeries$, + ]).pipe( + map(([seriesName, books]) => { + const inSeries = books.filter( + (b) => b.metadata?.seriesName?.toLowerCase() === seriesName + ); + return inSeries.sort((a, b) => { + const aNum = a.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER; + const bNum = b.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER; + return aNum - bNum; + }); + }) + ); + + seriesTitle$: Observable = combineLatest([ + this.seriesParam$, + this.filteredBooks$, + ]).pipe(map(([param, books]) => books[0]?.metadata?.seriesName || param)); + + yearsRange$: Observable = this.filteredBooks$.pipe( + map((books) => { + const years = books + .map((b) => b.metadata?.publishedDate) + .filter((d): d is string => !!d) + .map((d) => { + const match = d.match(/\d{4}/); + return match ? parseInt(match[0], 10) : null; + }) + .filter((y): y is number => y !== null); + + if (years.length === 0) return null; + const min = Math.min(...years); + const max = Math.max(...years); + return min === max ? String(min) : `${min}-${max}`; + }) + ); + + firstBookWithDesc$: Observable = this.filteredBooks$.pipe( + map((books) => books[0]), + filter((b): b is Book => !!b), + switchMap((b) => this.bookService.getBookByIdFromAPI(b.id, true)) + ); + + firstDescription$: Observable = this.firstBookWithDesc$.pipe( + map((b) => b.metadata?.description || "") + ); + + seriesReadStatus$: Observable = this.filteredBooks$.pipe( + map((books) => { + if (!books || books.length === 0) return ReadStatus.UNREAD; + const statuses = books.map((b) => (b.readStatus as ReadStatus) ?? ReadStatus.UNREAD); + + const hasWontRead = statuses.includes(ReadStatus.WONT_READ); + if (hasWontRead) return ReadStatus.WONT_READ; + + const hasAbandoned = statuses.includes(ReadStatus.ABANDONED); + if (hasAbandoned) return ReadStatus.ABANDONED; + + const allRead = statuses.every((s) => s === ReadStatus.READ); + if (allRead) return ReadStatus.READ; + + const someRead = statuses.some((s) => s === ReadStatus.READ); + if (someRead) return ReadStatus.PARTIALLY_READ; + + const allUnread = statuses.every((s) => s === ReadStatus.UNREAD); + if (allUnread) return ReadStatus.UNREAD; + + return ReadStatus.PARTIALLY_READ; + }) + ); + + get currentCardSize() { + return this.coverScalePreferenceService.currentCardSize; + } + + get gridColumnMinWidth(): string { + return this.coverScalePreferenceService.gridColumnMinWidth; + } + + goToAuthorBooks(author: string): void { + this.handleMetadataClick("author", author); + } + + goToCategory(category: string): void { + this.handleMetadataClick("category", category); + } + + goToPublisher(publisher: string): void { + this.handleMetadataClick("publisher", publisher); + } + + private navigateToFilteredBooks( + filterKey: string, + filterValue: string + ): void { + this.router.navigate(["/all-books"], { + queryParams: { + view: "grid", + sort: "title", + direction: "asc", + sidebar: true, + filter: `${filterKey}:${filterValue}`, + }, + }); + } + + private handleMetadataClick(filterKey: string, filterValue: string): void { + if (this.metadataCenterViewMode === "dialog") { + this.dialogRef?.close(); + setTimeout( + () => this.navigateToFilteredBooks(filterKey, filterValue), + 200 + ); + } else { + this.navigateToFilteredBooks(filterKey, filterValue); + } + } + + toggleExpand(): void { + this.isExpanded = !this.isExpanded; + } + + getStatusLabel(value: string | ReadStatus | null | undefined): string { + const v = (value ?? '').toString().toUpperCase(); + switch (v) { + case ReadStatus.UNREAD: + return 'UNREAD'; + case ReadStatus.READ: + return 'READ'; + case ReadStatus.PARTIALLY_READ: + return 'PARTIALLY READ'; + case ReadStatus.ABANDONED: + return 'ABANDONED'; + case ReadStatus.WONT_READ: + return "WON'T READ"; + default: + return 'UNSET'; + } + } + + getStatusSeverityClass(status: string): string { + const normalized = status?.toUpperCase(); + switch (normalized) { + case "UNREAD": + return "bg-gray-500"; + case "READ": + return "bg-green-600"; + case "PARTIALLY_READ": + return "bg-yellow-600"; + case "ABANDONED": + return "bg-red-600"; + case "WONT_READ": + return "bg-pink-700"; + default: + return "bg-gray-600"; + } + } +} diff --git a/booklore-ui/src/app/book/service/book.service.ts b/booklore-ui/src/app/book/service/book.service.ts index ba6a32e0c..22f755ca4 100644 --- a/booklore-ui/src/app/book/service/book.service.ts +++ b/booklore-ui/src/app/book/service/book.service.ts @@ -510,6 +510,10 @@ export class BookService { ); } + getComicInfoMetadata(bookId: number): Observable { + return this.http.get(`${this.url}/${bookId}/cbx/metadata/comicinfo`); + } + autoRefreshMetadata(metadataRefreshRequest: MetadataRefreshRequest): Observable { return this.http.put(`${this.url}/metadata/refresh`, metadataRefreshRequest).pipe( map(() => { @@ -574,10 +578,7 @@ export class BookService { } const seriesName = currentBook.metadata.seriesName.toLowerCase(); - return allBooks.filter(b => - b.id !== bookId && - b.metadata?.seriesName?.toLowerCase() === seriesName - ); + return allBooks.filter(b => b.metadata?.seriesName?.toLowerCase() === seriesName); }) ); } diff --git a/booklore-ui/src/app/book/service/sort.service.ts b/booklore-ui/src/app/book/service/sort.service.ts index 7f120aeb6..6fe9a7ca9 100644 --- a/booklore-ui/src/app/book/service/sort.service.ts +++ b/booklore-ui/src/app/book/service/sort.service.ts @@ -8,7 +8,8 @@ import {SortDirection, SortOption} from "../model/sort.model"; export class SortService { private readonly fieldExtractors: Record any> = { - title: (book) => book.metadata?.title?.toLowerCase() || null, + title: (book) => (book.seriesCount ? (book.metadata?.seriesName?.toLowerCase() || null) : null) + ?? (book.metadata?.title?.toLowerCase() || null), titleSeries: (book) => { const title = book.metadata?.title?.toLowerCase() || ''; const series = book.metadata?.seriesName?.toLowerCase(); diff --git a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss index 6c1ca1ece..0ed91cb0c 100644 --- a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss +++ b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss @@ -4,5 +4,7 @@ } .live-border { - border: 0.5px solid var(--primary-color); + background: var(--card-background); + border: 1px solid var(--primary-color); + border-radius: 0.5rem; } diff --git a/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss b/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss index 2636cb2e7..59773448d 100644 --- a/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss +++ b/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss @@ -1,3 +1,5 @@ .live-border { - border: 0.5px solid var(--primary-color); + background: var(--card-background); + border: 1px solid var(--primary-color); + border-radius: 0.5rem; } diff --git a/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss b/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss index a434fb8e7..0e766b8e3 100644 --- a/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss +++ b/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss @@ -1,4 +1,7 @@ .live-border { - border: 0.5px solid var(--primary-color); + background: var(--card-background); + border: 1px solid var(--primary-color); + border-radius: 0.5rem; + } diff --git a/booklore-ui/src/app/core/model/app-settings.model.ts b/booklore-ui/src/app/core/model/app-settings.model.ts index 809626925..91014b217 100644 --- a/booklore-ui/src/app/core/model/app-settings.model.ts +++ b/booklore-ui/src/app/core/model/app-settings.model.ts @@ -81,7 +81,9 @@ export interface Douban { } export interface MetadataPersistenceSettings { + moveFilesToLibraryPattern: boolean; saveToOriginalFile: boolean; + convertCbrCb7ToCbz: boolean; backupMetadata: boolean; backupCover: boolean; } diff --git a/booklore-ui/src/app/core/model/app-state.model.ts b/booklore-ui/src/app/core/model/app-state.model.ts index 823544493..4c6b23042 100644 --- a/booklore-ui/src/app/core/model/app-state.model.ts +++ b/booklore-ui/src/app/core/model/app-state.model.ts @@ -2,4 +2,9 @@ export interface AppState { preset?: string; primary?: string; surface?: string; + backgroundImage?: string; + backgroundBlur?: number; + showBackground?: boolean; + lastUpdated?: number; // Not persisted, used for cache busting + surfaceAlpha?: number; } diff --git a/booklore-ui/src/app/core/service/app-config.service.ts b/booklore-ui/src/app/core/service/app-config.service.ts index 477f9911c..97d05a4db 100644 --- a/booklore-ui/src/app/core/service/app-config.service.ts +++ b/booklore-ui/src/app/core/service/app-config.service.ts @@ -1,8 +1,9 @@ import {DOCUMENT, isPlatformBrowser} from '@angular/common'; import {effect, inject, Injectable, PLATFORM_ID, signal} from '@angular/core'; -import {$t, updatePreset, updateSurfacePalette} from '@primeng/themes'; +import {$t} from '@primeng/themes'; import Aura from '@primeng/themes/aura'; import {AppState} from '../model/app-state.model'; +import {UrlHelperService} from '../../utilities/service/url-helper.service'; type ColorPalette = Record; @@ -15,10 +16,15 @@ interface Palette { providedIn: 'root', }) export class AppConfigService { + public static readonly DEFAULT_BACKGROUND_BLUR = 20; + public static readonly DEFAULT_SURFACE_ALPHA = 0.88; + public static readonly DEFAULT_PRIMARY_COLOR = 'indigo'; + private readonly STORAGE_KEY = 'appConfigState'; appState = signal({}); document = inject(DOCUMENT); platformId = inject(PLATFORM_ID); + private readonly urlHelper = inject(UrlHelperService); private initialized = false; readonly surfaces: Palette[] = [ @@ -162,17 +168,20 @@ export class AppConfigService { constructor() { const initialState = this.loadAppState(); - this.appState.set({...initialState}); + this.appState.set(initialState); this.document.documentElement.classList.add('p-dark'); if (isPlatformBrowser(this.platformId)) { - this.onPresetChange(); + this.setBackendImage(); + setTimeout(() => { + this.onPresetChange(); + this.initialized = true; + }, 0); } effect(() => { const state = this.appState(); if (!this.initialized || !state) { - this.initialized = true; return; } this.saveAppState(state); @@ -180,33 +189,87 @@ export class AppConfigService { }, {allowSignalWrites: true}); } + private setBackendImage(): void { + const backendUrl = this.urlHelper.getBackgroundImageUrl(Date.now()); + this.appState.update(state => ({ + ...state, + backgroundImage: backendUrl, + lastUpdated: Date.now() + })); + } + + refreshBackgroundImage(): void { + const timestamp = Date.now(); + const backendUrl = this.urlHelper.getBackgroundImageUrl(timestamp); + this.appState.update(state => ({ + ...state, + backgroundImage: backendUrl, + lastUpdated: timestamp + })); + } + private loadAppState(): AppState { + const defaultState: AppState = { + preset: 'Aura', + primary: AppConfigService.DEFAULT_PRIMARY_COLOR, + surface: 'neutral', + backgroundBlur: AppConfigService.DEFAULT_BACKGROUND_BLUR, + showBackground: true, + surfaceAlpha: AppConfigService.DEFAULT_SURFACE_ALPHA, + }; + if (isPlatformBrowser(this.platformId)) { const storedState = localStorage.getItem(this.STORAGE_KEY); if (storedState) { - return JSON.parse(storedState); + try { + const parsed = JSON.parse(storedState); + return { + preset: parsed.preset || defaultState.preset, + primary: parsed.primary || defaultState.primary, + surface: parsed.surface || defaultState.surface, + backgroundBlur: parsed.backgroundBlur ?? defaultState.backgroundBlur, + showBackground: parsed.showBackground ?? defaultState.showBackground, + surfaceAlpha: parsed.surfaceAlpha ?? defaultState.surfaceAlpha, + }; + } catch (error) { + return defaultState; + } } } - return { - preset: 'Aura', - primary: 'green', - surface: 'neutral', - }; + return defaultState; } private saveAppState(state: AppState): void { if (isPlatformBrowser(this.platformId)) { - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state)); + const {backgroundImage, lastUpdated, ...stateToSave} = state; + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(stateToSave)); } } private getSurfacePalette(surface: string): ColorPalette { - return this.surfaces.find(s => s.name === surface)?.palette ?? {}; + const palette = this.surfaces.find(s => s.name === surface)?.palette ?? {}; + const alpha = this.appState().surfaceAlpha ?? AppConfigService.DEFAULT_SURFACE_ALPHA; + const transparentPalette: ColorPalette = {}; + + // Text/content colors that should remain opaque (not transparent) + const opaqueKeys = ['0', '50', '100', '200', '300', '400']; + + Object.entries(palette).forEach(([key, hex]) => { + if (opaqueKeys.includes(key)) { + // Keep text colors opaque + transparentPalette[key] = hex; + } else { + // Apply transparency to background colors (500-950) + transparentPalette[key] = this.hexToRgba(hex, alpha); + } + }); + + return transparentPalette; } getPresetExt(): object { const surfacePalette = this.getSurfacePalette(this.appState().surface ?? 'neutral'); - const primaryName = this.appState().primary ?? 'green'; + const primaryName = this.appState().primary ?? AppConfigService.DEFAULT_PRIMARY_COLOR; const presetPalette = (Aura.primitive ?? {}) as Record; const color = presetPalette[primaryName] ?? {}; @@ -248,8 +311,8 @@ export class AppConfigService { highlight: { background: 'color-mix(in srgb, {primary.400}, transparent 84%)', focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)', - color: 'rgba(255,255,255,.87)', - focusColor: 'rgba(255,255,255,.87)' + color: 'rgba(255,255,255,.88)', + focusColor: 'rgba(255,255,255,.88)' } } } @@ -257,9 +320,30 @@ export class AppConfigService { }; } + private hexToRgba(hex: string, alpha: number = AppConfigService.DEFAULT_SURFACE_ALPHA): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + onPresetChange(): void { const surfacePalette = this.getSurfacePalette(this.appState().surface ?? 'neutral'); const preset = this.getPresetExt(); $t().preset(Aura).preset(preset).surfacePalette(surfacePalette).use({useDefaultOptions: true}); } + + updateBackgroundBlur(blur: number): void { + this.appState.update(state => ({ + ...state, + backgroundBlur: blur + })); + } + + updateSurfaceAlpha(alpha: number): void { + this.appState.update(state => ({ + ...state, + surfaceAlpha: alpha + })); + } } diff --git a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.scss b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.scss index 4aabd9b01..62d8e96a4 100644 --- a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.scss +++ b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.scss @@ -25,12 +25,13 @@ position: absolute; top: -0.4rem; right: -0.4rem; - background-color: var(--red-500); - color: yellowgreen; - padding: 0 8px; - font-size: 1rem; - font-weight: 400; - line-height: 1.2; + background-color: red; + color: white; + border-radius: 50%; + width: 1.2rem; + height: 1.2rem; + font-size: 0.75rem; + font-weight: 600; display: flex; align-items: center; justify-content: center; diff --git a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts index 687092f9d..e7840881b 100644 --- a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts +++ b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts @@ -237,8 +237,8 @@ export class AppTopBarComponent implements OnDestroy { get iconColor(): string { if (this.progressHighlight) return 'yellow'; - if (this.showPulse) return 'red'; - if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles) return 'orange'; + if (this.showPulse) return 'orange'; + if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles) return 'yellowgreen'; return 'inherit'; } diff --git a/booklore-ui/src/app/layout/component/theme-configurator/background-upload.service.ts b/booklore-ui/src/app/layout/component/theme-configurator/background-upload.service.ts new file mode 100644 index 000000000..618060cd2 --- /dev/null +++ b/booklore-ui/src/app/layout/component/theme-configurator/background-upload.service.ts @@ -0,0 +1,32 @@ +import {Injectable, inject} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable, map, tap} from 'rxjs'; +import {API_CONFIG} from '../../../config/api-config'; + +@Injectable({providedIn: 'root'}) +export class BackgroundUploadService { + private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v1/background`; + private readonly http = inject(HttpClient); + + uploadFile(file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + return this.http.post<{ url: string }>(`${this.baseUrl}/upload`, formData).pipe( + tap(response => console.log('File upload response:', response)), + map(resp => resp?.url) + ); + } + + uploadUrl(url: string): Observable { + return this.http.post<{ url: string }>(`${this.baseUrl}/url`, {url}).pipe( + tap(response => console.log('URL upload response:', response)), + map(resp => resp?.url) + ); + } + + resetToDefault(): Observable { + return this.http.delete(this.baseUrl).pipe( + tap(() => console.log('Background reset to default')) + ); + } +} diff --git a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.html b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.html index 0ca7f3072..cd40e5be6 100644 --- a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.html +++ b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.html @@ -28,6 +28,67 @@ } +
+ Transparency: {{ (1 - surfaceAlphaValue).toFixed(2) }} + + +
+ + +
+ Background +
+
+
+ + + +
+ @if (backgroundVisible) { +
+ + + +
+ } +
+ @if (backgroundVisible) { +
+ + + + +
+ } +
diff --git a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.scss b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.scss index e69de29bb..6767b5627 100644 --- a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.scss +++ b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.scss @@ -0,0 +1,95 @@ +.config-panel-section { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--surface-border); +} + +.config-panel-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + + label { + font-weight: 500; + color: var(--text-color); + } + + input[type="text"], input[type="range"] { + padding: 0.5rem; + border: 1px solid var(--surface-border); + border-radius: var(--border-radius); + background: var(--surface-ground); + color: var(--text-color); + } + + input[type="range"] { + width: 100%; + } +} + +// Background section styles +.config-panel-colors { + .surface-alpha-control { + display: flex; + flex-direction: column; + margin-top: 0.75rem; + padding: 0.5rem 0; + + .config-panel-label { + margin-bottom: 0.5rem; + } + + .alpha-slider { + width: 100%; + } + } + + .background-center-wrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .background-controls { + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + } + + .background-control { + display: flex; + align-items: center; + padding-top: 0.5rem; + padding-bottom: 0.1rem; + + label { + min-width: 80px; + font-size: 0.85em; + font-weight: 500; + color: var(--text-secondary-color); + } + + .blur-slider { + flex: 1; + margin-left: 0.5rem; + } + } + + .background-actions-row { + display: flex; + flex-direction: row; + gap: 0.75rem; + margin-top: 0.75rem; + width: 100%; + box-sizing: border-box; + align-items: center; + justify-content: flex-end; + + .upload-bg-btn { + margin-left: 0; + } + } + } +} diff --git a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.ts b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.ts index 236ea2def..fd7ebab0a 100644 --- a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.ts +++ b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.ts @@ -1,13 +1,19 @@ import {CommonModule} from '@angular/common'; import {Component, computed, effect, inject} from '@angular/core'; import {FormsModule} from '@angular/forms'; -import {$t} from '@primeng/themes'; import Aura from '@primeng/themes/aura'; import {ButtonModule} from 'primeng/button'; import {RadioButtonModule} from 'primeng/radiobutton'; import {ToggleSwitchModule} from 'primeng/toggleswitch'; +import {InputTextModule} from 'primeng/inputtext'; +import {SliderModule, SliderSlideEndEvent} from 'primeng/slider'; import {AppConfigService} from '../../../core/service/app-config.service'; import {FaviconService} from './favicon-service'; +import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog'; +import {UploadDialogComponent} from './upload-dialog/upload-dialog.component'; +import {UrlHelperService} from '../../../utilities/service/url-helper.service'; +import {BackgroundUploadService} from './background-upload.service'; +import {debounceTime, Subject} from 'rxjs'; type ColorPalette = Record; @@ -20,6 +26,7 @@ interface Palette { selector: 'app-theme-configurator', standalone: true, templateUrl: './theme-configurator.component.html', + styleUrls: ['./theme-configurator.component.scss'], host: { class: 'config-panel hidden' }, @@ -28,12 +35,17 @@ interface Palette { FormsModule, ButtonModule, RadioButtonModule, - ToggleSwitchModule - ] + ToggleSwitchModule, + InputTextModule, + SliderModule + ], + providers: [DialogService] }) export class ThemeConfiguratorComponent { readonly configService = inject(AppConfigService); readonly faviconService = inject(FaviconService); + readonly urlHelper = inject(UrlHelperService); + private readonly backgroundUploadService = inject(BackgroundUploadService); readonly surfaces = this.configService.surfaces; @@ -41,7 +53,7 @@ export class ThemeConfiguratorComponent { readonly selectedSurfaceColor = computed(() => this.configService.appState().surface); readonly faviconColor = computed(() => { - const name = this.selectedPrimaryColor() ?? 'green'; + const name = this.selectedPrimaryColor() ?? AppConfigService.DEFAULT_PRIMARY_COLOR; const presetPalette = (Aura.primitive ?? {}) as Record; const colorPalette = presetPalette[name]; return colorPalette?.[500] ?? name; @@ -62,6 +74,64 @@ export class ThemeConfiguratorComponent { ); }); + get backgroundVisible(): boolean { + return this.configService.appState().showBackground ?? true; + } + + set backgroundVisible(value: boolean) { + this.configService.appState.update(state => ({ + ...state, + showBackground: value + })); + } + + get backgroundBlurValue(): number { + return this.configService.appState().backgroundBlur ?? AppConfigService.DEFAULT_BACKGROUND_BLUR; + } + + set backgroundBlurValue(value: number) { + this.configService.updateBackgroundBlur(value); + } + + get surfaceAlphaValue(): number { + return this.configService.appState().surfaceAlpha ?? AppConfigService.DEFAULT_SURFACE_ALPHA; + } + + set surfaceAlphaValue(value: number) { + this.configService.updateSurfaceAlpha(value); + } + + private readonly dialogService = inject(DialogService); + private dialogRef: DynamicDialogRef | undefined; + + private surfaceAlphaSubject = new Subject(); + + constructor() { + this.surfaceAlphaSubject.pipe( + debounceTime(100) + ).subscribe(value => { + this.configService.updateSurfaceAlpha(value); + }); + } + + openUploadDialog() { + this.dialogRef = this.dialogService.open(UploadDialogComponent, { + header: 'Upload or Paste Image URL', + width: '450px', + modal: true, + closable: true, + data: {} + }); + + this.dialogRef.onClose.subscribe((result) => { + if (result) { + if (result.success || result.uploaded || result.url || result.imageUrl) { + this.configService.refreshBackgroundImage(); + } + } + }); + } + updateColors(event: Event, type: 'primary' | 'surface', color: { name: string; palette?: ColorPalette }) { this.configService.appState.update((state) => ({ ...state, @@ -69,4 +139,26 @@ export class ThemeConfiguratorComponent { })); event.stopPropagation(); } + + updateBackgroundBlur(event: SliderSlideEndEvent): void { + this.configService.appState.update(state => ({ + ...state, + backgroundBlur: Number(event.value) + })); + } + + updateSurfaceAlpha(event: SliderSlideEndEvent): void { + this.surfaceAlphaSubject.next(Number(event.value)); + } + + resetBackground() { + this.backgroundUploadService.resetToDefault().subscribe({ + next: () => { + this.configService.refreshBackgroundImage(); + }, + error: (err) => { + console.error('Failed to reset background:', err); + } + }); + } } diff --git a/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.html b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.html new file mode 100644 index 000000000..89bd134f8 --- /dev/null +++ b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.html @@ -0,0 +1,59 @@ +
+
+
+ + + +
+ + Only JPG and PNG files are supported + @if (uploadFile) { + {{ uploadFile.name }} + } +
+ + + OR + + +
+ + + Only JPG and PNG URLs are supported +
+ + @if (uploadError) { + {{uploadError}} + } + + +
diff --git a/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.scss b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.scss new file mode 100644 index 000000000..01319b44c --- /dev/null +++ b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.scss @@ -0,0 +1,82 @@ +.upload-dialog-content { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1rem 0; + + .upload-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + + .file-selector { + display: flex; + align-items: center; + gap: 1rem; + } + + .file-label { + font-weight: 500; + color: var(--text-color); + } + + .file-input { + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: var(--p-border-radius); + background: var(--ground-background); + } + + .file-note { + color: var(--text-secondary-color); + font-size: 0.875rem; + margin-top: 0.25rem; + } + + .selected-file { + color: var(--text-secondary-color); + font-style: italic; + } + } + + .url-section { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .url-label { + font-weight: 500; + color: var(--text-color); + margin-bottom: 0.5rem; + } + + .url-input { + width: 100%; + padding: 0.75rem; + } + + .url-note { + color: var(--text-secondary-color); + font-size: 0.875rem; + margin-top: 0.25rem; + } + } + + .or-text { + font-size: 0.9rem; + color: var(--text-color); + font-weight: 500; + } + + p-message { + margin-top: 1rem; + } + + .dialog-footer { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; + } +} diff --git a/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.ts b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.ts new file mode 100644 index 000000000..ddd751daf --- /dev/null +++ b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.ts @@ -0,0 +1,72 @@ +import {Component, inject} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {DynamicDialogRef} from 'primeng/dynamicdialog'; +import {ButtonModule} from 'primeng/button'; +import {InputTextModule} from 'primeng/inputtext'; +import {DividerModule} from 'primeng/divider'; +import {MessageModule} from 'primeng/message'; +import {BackgroundUploadService} from '../background-upload.service'; +import {take} from 'rxjs'; + +@Component({ + selector: 'app-upload-dialog', + standalone: true, + templateUrl: './upload-dialog.component.html', + styleUrls: ['./upload-dialog.component.scss'], + imports: [ + CommonModule, + FormsModule, + ButtonModule, + InputTextModule, + DividerModule, + MessageModule + ] +}) +export class UploadDialogComponent { + private readonly dialogRef = inject(DynamicDialogRef); + private readonly backgroundUploadService = inject(BackgroundUploadService); + + uploadImageUrl = ''; + uploadFile: File | null = null; + uploadError = ''; + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.uploadFile = input.files[0]; + this.uploadImageUrl = ''; + } + } + + submit() { + this.uploadError = ''; + let upload$; + if (this.uploadFile) { + upload$ = this.backgroundUploadService.uploadFile(this.uploadFile); + } else if (this.uploadImageUrl.trim()) { + upload$ = this.backgroundUploadService.uploadUrl(this.uploadImageUrl.trim()); + } else { + this.uploadError = 'Please select a file or paste a URL.'; + return; + } + + upload$.pipe(take(1)).subscribe({ + next: (imageUrl) => { + if (imageUrl) { + this.dialogRef.close({ imageUrl }); + } else { + this.uploadError = 'Failed to upload image.'; + } + }, + error: (err) => { + console.error('Upload failed:', err); + this.uploadError = 'Upload failed. Please try again.'; + } + }); + } + + cancel() { + this.dialogRef.close(); + } +} diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html index 1a8f96f24..96b237146 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html @@ -452,6 +452,11 @@ tooltipPosition="top"> + @if (book.bookType === 'CBX') { + + + } + @if (book.bookType === 'PDF' || book.bookType === 'EPUB') { @@ -486,6 +491,17 @@ tooltipPosition="top"> + @if (book.bookType === 'CBX') { + + + } + @if (book.bookType === 'PDF' || book.bookType === 'EPUB') { ; @Output() nextBookClicked = new EventEmitter(); @Output() previousBookClicked = new EventEmitter(); @@ -83,47 +104,47 @@ export class MetadataEditorComponent implements OnInit { filterCategories(event: { query: string }) { const query = event.query.toLowerCase(); - this.filteredCategories = this.allCategories.filter(cat => + this.filteredCategories = this.allCategories.filter((cat) => cat.toLowerCase().includes(query) ); } filterAuthors(event: { query: string }) { const query = event.query.toLowerCase(); - this.filteredAuthors = this.allAuthors.filter(cat => + this.filteredAuthors = this.allAuthors.filter((cat) => cat.toLowerCase().includes(query) ); } constructor() { this.metadataForm = new FormGroup({ - title: new FormControl(''), - subtitle: new FormControl(''), - authors: new FormControl(''), - categories: new FormControl(''), - publisher: new FormControl(''), - publishedDate: new FormControl(''), - isbn10: new FormControl(''), - isbn13: new FormControl(''), - description: new FormControl(''), - pageCount: new FormControl(''), - language: new FormControl(''), - asin: new FormControl(''), - personalRating: new FormControl(''), - amazonRating: new FormControl(''), - amazonReviewCount: new FormControl(''), - goodreadsId: new FormControl(''), - comicvineId: new FormControl(''), - goodreadsRating: new FormControl(''), - goodreadsReviewCount: new FormControl(''), - hardcoverId: new FormControl(''), - hardcoverRating: new FormControl(''), - hardcoverReviewCount: new FormControl(''), - googleId: new FormControl(''), - seriesName: new FormControl(''), - seriesNumber: new FormControl(''), - seriesTotal: new FormControl(''), - thumbnailUrl: new FormControl(''), + title: new FormControl(""), + subtitle: new FormControl(""), + authors: new FormControl(""), + categories: new FormControl(""), + publisher: new FormControl(""), + publishedDate: new FormControl(""), + isbn10: new FormControl(""), + isbn13: new FormControl(""), + description: new FormControl(""), + pageCount: new FormControl(""), + language: new FormControl(""), + asin: new FormControl(""), + personalRating: new FormControl(""), + amazonRating: new FormControl(""), + amazonReviewCount: new FormControl(""), + goodreadsId: new FormControl(""), + comicvineId: new FormControl(""), + goodreadsRating: new FormControl(""), + goodreadsReviewCount: new FormControl(""), + hardcoverId: new FormControl(""), + hardcoverRating: new FormControl(""), + hardcoverReviewCount: new FormControl(""), + googleId: new FormControl(""), + seriesName: new FormControl(""), + seriesNumber: new FormControl(""), + seriesTotal: new FormControl(""), + thumbnailUrl: new FormControl(""), titleLocked: new FormControl(false), subtitleLocked: new FormControl(false), @@ -140,7 +161,7 @@ export class MetadataEditorComponent implements OnInit { personalRatingLocked: new FormControl(false), amazonRatingLocked: new FormControl(false), amazonReviewCountLocked: new FormControl(false), - goodreadsIdLocked: new FormControl(''), + goodreadsIdLocked: new FormControl(""), comicvineIdLocked: new FormControl(false), goodreadsRatingLocked: new FormControl(false), goodreadsReviewCountLocked: new FormControl(false), @@ -156,32 +177,32 @@ export class MetadataEditorComponent implements OnInit { } ngOnInit(): void { - this.book$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(book => { - const metadata = book?.metadata; - if (!metadata) return; - this.currentBookId = metadata.bookId; - if (this.refreshingBookIds.has(book.id)) { - this.refreshingBookIds.delete(book.id); - this.isAutoFetching = false; - } - this.originalMetadata = structuredClone(metadata); - this.populateFormFromMetadata(metadata); - }); + this.book$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((book) => { + const metadata = book?.metadata; + if (!metadata) return; + this.currentBookId = metadata.bookId; + if (this.refreshingBookIds.has(book.id)) { + this.refreshingBookIds.delete(book.id); + this.isAutoFetching = false; + } + this.originalMetadata = structuredClone(metadata); + this.populateFormFromMetadata(metadata); + }); this.bookService.bookState$ .pipe( - filter(bookState => bookState.loaded), + filter((bookState) => bookState.loaded), take(1) ) - .subscribe(bookState => { + .subscribe((bookState) => { const authors = new Set(); const categories = new Set(); - (bookState.books ?? []).forEach(book => { - book.metadata?.authors?.forEach(author => authors.add(author)); - book.metadata?.categories?.forEach(category => categories.add(category)); + (bookState.books ?? []).forEach((book) => { + book.metadata?.authors?.forEach((author) => authors.add(author)); + book.metadata?.categories?.forEach((category) => + categories.add(category) + ); }); this.allAuthors = Array.from(authors); @@ -249,36 +270,36 @@ export class MetadataEditorComponent implements OnInit { }); const lockableFields: { key: keyof BookMetadata; control: string }[] = [ - {key: 'titleLocked', control: 'title'}, - {key: 'subtitleLocked', control: 'subtitle'}, - {key: 'authorsLocked', control: 'authors'}, - {key: 'categoriesLocked', control: 'categories'}, - {key: 'publisherLocked', control: 'publisher'}, - {key: 'publishedDateLocked', control: 'publishedDate'}, - {key: 'languageLocked', control: 'language'}, - {key: 'isbn10Locked', control: 'isbn10'}, - {key: 'isbn13Locked', control: 'isbn13'}, - {key: 'asinLocked', control: 'asin'}, - {key: 'amazonReviewCountLocked', control: 'amazonReviewCount'}, - {key: 'amazonRatingLocked', control: 'amazonRating'}, - {key: 'personalRatingLocked', control: 'personalRating'}, - {key: 'goodreadsIdLocked', control: 'goodreadsId'}, - {key: 'comicvineIdLocked', control: 'comicvineId'}, - {key: 'goodreadsReviewCountLocked', control: 'goodreadsReviewCount'}, - {key: 'goodreadsRatingLocked', control: 'goodreadsRating'}, - {key: 'hardcoverIdLocked', control: 'hardcoverId'}, - {key: 'hardcoverReviewCountLocked', control: 'hardcoverReviewCount'}, - {key: 'hardcoverRatingLocked', control: 'hardcoverRating'}, - {key: 'googleIdLocked', control: 'googleId'}, - {key: 'pageCountLocked', control: 'pageCount'}, - {key: 'descriptionLocked', control: 'description'}, - {key: 'seriesNameLocked', control: 'seriesName'}, - {key: 'seriesNumberLocked', control: 'seriesNumber'}, - {key: 'seriesTotalLocked', control: 'seriesTotal'}, - {key: 'coverLocked', control: 'thumbnailUrl'}, + { key: "titleLocked", control: "title" }, + { key: "subtitleLocked", control: "subtitle" }, + { key: "authorsLocked", control: "authors" }, + { key: "categoriesLocked", control: "categories" }, + { key: "publisherLocked", control: "publisher" }, + { key: "publishedDateLocked", control: "publishedDate" }, + { key: "languageLocked", control: "language" }, + { key: "isbn10Locked", control: "isbn10" }, + { key: "isbn13Locked", control: "isbn13" }, + { key: "asinLocked", control: "asin" }, + { key: "amazonReviewCountLocked", control: "amazonReviewCount" }, + { key: "amazonRatingLocked", control: "amazonRating" }, + { key: "personalRatingLocked", control: "personalRating" }, + { key: "goodreadsIdLocked", control: "goodreadsId" }, + { key: "comicvineIdLocked", control: "comicvineId" }, + { key: "goodreadsReviewCountLocked", control: "goodreadsReviewCount" }, + { key: "goodreadsRatingLocked", control: "goodreadsRating" }, + { key: "hardcoverIdLocked", control: "hardcoverId" }, + { key: "hardcoverReviewCountLocked", control: "hardcoverReviewCount" }, + { key: "hardcoverRatingLocked", control: "hardcoverRating" }, + { key: "googleIdLocked", control: "googleId" }, + { key: "pageCountLocked", control: "pageCount" }, + { key: "descriptionLocked", control: "description" }, + { key: "seriesNameLocked", control: "seriesName" }, + { key: "seriesNumberLocked", control: "seriesNumber" }, + { key: "seriesTotalLocked", control: "seriesTotal" }, + { key: "coverLocked", control: "thumbnailUrl" }, ]; - for (const {key, control} of lockableFields) { + for (const { key, control } of lockableFields) { const isLocked = metadata[key] === true; const formControl = this.metadataForm.get(control); if (formControl) { @@ -292,11 +313,11 @@ export class MetadataEditorComponent implements OnInit { if (!values.includes(event.value)) { this.metadataForm.get(fieldName)?.setValue([...values, event.value]); } - (event.originalEvent.target as HTMLInputElement).value = ''; + (event.originalEvent.target as HTMLInputElement).value = ""; } onAutoCompleteKeyUp(fieldName: string, event: KeyboardEvent) { - if (event.key === 'Enter') { + if (event.key === "Enter") { const input = event.target as HTMLInputElement; const value = input.value?.trim(); if (value) { @@ -304,36 +325,46 @@ export class MetadataEditorComponent implements OnInit { if (!values.includes(value)) { this.metadataForm.get(fieldName)?.setValue([...values, value]); } - input.value = ''; + input.value = ""; } } } onSave(): void { this.isSaving = true; - this.bookService.updateBookMetadata(this.currentBookId, this.buildMetadataWrapper(undefined), false).subscribe({ - next: (response) => { - this.isSaving = false; - this.messageService.add({severity: 'info', summary: 'Success', detail: 'Book metadata updated'}); - }, - error: (err) => { - this.isSaving = false; - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: err?.error?.message || 'Failed to update book metadata' - }); - } - }); + this.bookService + .updateBookMetadata( + this.currentBookId, + this.buildMetadataWrapper(undefined), + false + ) + .subscribe({ + next: (response) => { + this.isSaving = false; + this.messageService.add({ + severity: "info", + summary: "Success", + detail: "Book metadata updated", + }); + }, + error: (err) => { + this.isSaving = false; + this.messageService.add({ + severity: "error", + summary: "Error", + detail: err?.error?.message || "Failed to update book metadata", + }); + }, + }); } toggleLock(field: string): void { - if (field === 'thumbnailUrl') { - field = 'cover' + if (field === "thumbnailUrl") { + field = "cover"; } - const isLocked = this.metadataForm.get(field + 'Locked')?.value; + const isLocked = this.metadataForm.get(field + "Locked")?.value; const updatedLockedState = !isLocked; - this.metadataForm.get(field + 'Locked')?.setValue(updatedLockedState); + this.metadataForm.get(field + "Locked")?.setValue(updatedLockedState); if (updatedLockedState) { this.metadataForm.get(field)?.disable(); } else { @@ -344,9 +375,9 @@ export class MetadataEditorComponent implements OnInit { lockAll(): void { Object.keys(this.metadataForm.controls).forEach((key) => { - if (key.endsWith('Locked')) { + if (key.endsWith("Locked")) { this.metadataForm.get(key)?.setValue(true); - const fieldName = key.replace('Locked', ''); + const fieldName = key.replace("Locked", ""); this.metadataForm.get(fieldName)?.disable(); } }); @@ -355,9 +386,9 @@ export class MetadataEditorComponent implements OnInit { unlockAll(): void { Object.keys(this.metadataForm.controls).forEach((key) => { - if (key.endsWith('Locked')) { + if (key.endsWith("Locked")) { this.metadataForm.get(key)?.setValue(false); - const fieldName = key.replace('Locked', ''); + const fieldName = key.replace("Locked", ""); this.metadataForm.get(fieldName)?.enable(); } }); @@ -365,74 +396,78 @@ export class MetadataEditorComponent implements OnInit { } quillDisabled(): boolean { - return this.metadataForm.get('descriptionLocked')?.value === true; + return this.metadataForm.get("descriptionLocked")?.value === true; } - private buildMetadataWrapper(shouldLockAllFields?: boolean): MetadataUpdateWrapper { + private buildMetadataWrapper( + shouldLockAllFields?: boolean + ): MetadataUpdateWrapper { const form = this.metadataForm; const metadata: BookMetadata = { bookId: this.currentBookId, - title: form.get('title')?.value, - subtitle: form.get('subtitle')?.value, - authors: form.get('authors')?.value ?? [], - categories: form.get('categories')?.value ?? [], - publisher: form.get('publisher')?.value, - publishedDate: form.get('publishedDate')?.value, - isbn10: form.get('isbn10')?.value, - isbn13: form.get('isbn13')?.value, - description: form.get('description')?.value, - pageCount: form.get('pageCount')?.value, - rating: form.get('rating')?.value, - reviewCount: form.get('reviewCount')?.value, - asin: form.get('asin')?.value, - personalRating: form.get('personalRating')?.value, - amazonRating: form.get('amazonRating')?.value, - amazonReviewCount: form.get('amazonReviewCount')?.value, - goodreadsId: form.get('goodreadsId')?.value, - comicvineId: form.get('comicvineId')?.value, - goodreadsRating: form.get('goodreadsRating')?.value, - goodreadsReviewCount: form.get('goodreadsReviewCount')?.value, - hardcoverId: form.get('hardcoverId')?.value, - hardcoverRating: form.get('hardcoverRating')?.value, - hardcoverReviewCount: form.get('hardcoverReviewCount')?.value, - googleId: form.get('googleId')?.value, - language: form.get('language')?.value, - seriesName: form.get('seriesName')?.value, - seriesNumber: form.get('seriesNumber')?.value, - seriesTotal: form.get('seriesTotal')?.value, - thumbnailUrl: form.get('thumbnailUrl')?.value, + title: form.get("title")?.value, + subtitle: form.get("subtitle")?.value, + authors: form.get("authors")?.value ?? [], + categories: form.get("categories")?.value ?? [], + publisher: form.get("publisher")?.value, + publishedDate: form.get("publishedDate")?.value, + isbn10: form.get("isbn10")?.value, + isbn13: form.get("isbn13")?.value, + description: form.get("description")?.value, + pageCount: form.get("pageCount")?.value, + rating: form.get("rating")?.value, + reviewCount: form.get("reviewCount")?.value, + asin: form.get("asin")?.value, + personalRating: form.get("personalRating")?.value, + amazonRating: form.get("amazonRating")?.value, + amazonReviewCount: form.get("amazonReviewCount")?.value, + goodreadsId: form.get("goodreadsId")?.value, + comicvineId: form.get("comicvineId")?.value, + goodreadsRating: form.get("goodreadsRating")?.value, + goodreadsReviewCount: form.get("goodreadsReviewCount")?.value, + hardcoverId: form.get("hardcoverId")?.value, + hardcoverRating: form.get("hardcoverRating")?.value, + hardcoverReviewCount: form.get("hardcoverReviewCount")?.value, + googleId: form.get("googleId")?.value, + language: form.get("language")?.value, + seriesName: form.get("seriesName")?.value, + seriesNumber: form.get("seriesNumber")?.value, + seriesTotal: form.get("seriesTotal")?.value, + thumbnailUrl: form.get("thumbnailUrl")?.value, // Locks - titleLocked: form.get('titleLocked')?.value, - subtitleLocked: form.get('subtitleLocked')?.value, - authorsLocked: form.get('authorsLocked')?.value, - categoriesLocked: form.get('categoriesLocked')?.value, - publisherLocked: form.get('publisherLocked')?.value, - publishedDateLocked: form.get('publishedDateLocked')?.value, - isbn10Locked: form.get('isbn10Locked')?.value, - isbn13Locked: form.get('isbn13Locked')?.value, - descriptionLocked: form.get('descriptionLocked')?.value, - pageCountLocked: form.get('pageCountLocked')?.value, - languageLocked: form.get('languageLocked')?.value, - asinLocked: form.get('asinLocked')?.value, - amazonRatingLocked: form.get('amazonRatingLocked')?.value, - personalRatingLocked: form.get('personalRatingLocked')?.value, - amazonReviewCountLocked: form.get('amazonReviewCountLocked')?.value, - goodreadsIdLocked: form.get('goodreadsIdLocked')?.value, - comicvineIdLocked: form.get('comicvineIdLocked')?.value, - goodreadsRatingLocked: form.get('goodreadsRatingLocked')?.value, - goodreadsReviewCountLocked: form.get('goodreadsReviewCountLocked')?.value, - hardcoverIdLocked: form.get('hardcoverIdLocked')?.value, - hardcoverRatingLocked: form.get('hardcoverRatingLocked')?.value, - hardcoverReviewCountLocked: form.get('hardcoverReviewCountLocked')?.value, - googleIdLocked: form.get('googleIdLocked')?.value, - seriesNameLocked: form.get('seriesNameLocked')?.value, - seriesNumberLocked: form.get('seriesNumberLocked')?.value, - seriesTotalLocked: form.get('seriesTotalLocked')?.value, - coverLocked: form.get('coverLocked')?.value, + titleLocked: form.get("titleLocked")?.value, + subtitleLocked: form.get("subtitleLocked")?.value, + authorsLocked: form.get("authorsLocked")?.value, + categoriesLocked: form.get("categoriesLocked")?.value, + publisherLocked: form.get("publisherLocked")?.value, + publishedDateLocked: form.get("publishedDateLocked")?.value, + isbn10Locked: form.get("isbn10Locked")?.value, + isbn13Locked: form.get("isbn13Locked")?.value, + descriptionLocked: form.get("descriptionLocked")?.value, + pageCountLocked: form.get("pageCountLocked")?.value, + languageLocked: form.get("languageLocked")?.value, + asinLocked: form.get("asinLocked")?.value, + amazonRatingLocked: form.get("amazonRatingLocked")?.value, + personalRatingLocked: form.get("personalRatingLocked")?.value, + amazonReviewCountLocked: form.get("amazonReviewCountLocked")?.value, + goodreadsIdLocked: form.get("goodreadsIdLocked")?.value, + comicvineIdLocked: form.get("comicvineIdLocked")?.value, + goodreadsRatingLocked: form.get("goodreadsRatingLocked")?.value, + goodreadsReviewCountLocked: form.get("goodreadsReviewCountLocked")?.value, + hardcoverIdLocked: form.get("hardcoverIdLocked")?.value, + hardcoverRatingLocked: form.get("hardcoverRatingLocked")?.value, + hardcoverReviewCountLocked: form.get("hardcoverReviewCountLocked")?.value, + googleIdLocked: form.get("googleIdLocked")?.value, + seriesNameLocked: form.get("seriesNameLocked")?.value, + seriesNumberLocked: form.get("seriesNumberLocked")?.value, + seriesTotalLocked: form.get("seriesTotalLocked")?.value, + coverLocked: form.get("coverLocked")?.value, - ...(shouldLockAllFields !== undefined && {allFieldsLocked: shouldLockAllFields}) + ...(shouldLockAllFields !== undefined && { + allFieldsLocked: shouldLockAllFields, + }), }; const original = this.originalMetadata; @@ -442,66 +477,70 @@ export class MetadataEditorComponent implements OnInit { const prev = (original[key] as any) ?? null; const isEmpty = (val: any): boolean => - val === null || val === '' || (Array.isArray(val) && val.length === 0); + val === null || val === "" || (Array.isArray(val) && val.length === 0); return isEmpty(current) && !isEmpty(prev); }; const clearFlags: MetadataClearFlags = { - title: wasCleared('title'), - subtitle: wasCleared('subtitle'), - authors: wasCleared('authors'), - categories: wasCleared('categories'), - publisher: wasCleared('publisher'), - publishedDate: wasCleared('publishedDate'), - isbn10: wasCleared('isbn10'), - isbn13: wasCleared('isbn13'), - description: wasCleared('description'), - pageCount: wasCleared('pageCount'), - language: wasCleared('language'), - asin: wasCleared('asin'), - personalRating: wasCleared('personalRating'), - amazonRating: wasCleared('personalRating'), - amazonReviewCount: wasCleared('amazonReviewCount'), - goodreadsId: wasCleared('goodreadsId'), - comicvineId: wasCleared('comicvineId'), - goodreadsRating: wasCleared('goodreadsRating'), - goodreadsReviewCount: wasCleared('goodreadsReviewCount'), - hardcoverId: wasCleared('hardcoverId'), - hardcoverRating: wasCleared('hardcoverRating'), - hardcoverReviewCount: wasCleared('hardcoverReviewCount'), - googleId: wasCleared('googleId'), - seriesName: wasCleared('seriesName'), - seriesNumber: wasCleared('seriesNumber'), - seriesTotal: wasCleared('seriesTotal'), - cover: false + title: wasCleared("title"), + subtitle: wasCleared("subtitle"), + authors: wasCleared("authors"), + categories: wasCleared("categories"), + publisher: wasCleared("publisher"), + publishedDate: wasCleared("publishedDate"), + isbn10: wasCleared("isbn10"), + isbn13: wasCleared("isbn13"), + description: wasCleared("description"), + pageCount: wasCleared("pageCount"), + language: wasCleared("language"), + asin: wasCleared("asin"), + personalRating: wasCleared("personalRating"), + amazonRating: wasCleared("personalRating"), + amazonReviewCount: wasCleared("amazonReviewCount"), + goodreadsId: wasCleared("goodreadsId"), + comicvineId: wasCleared("comicvineId"), + goodreadsRating: wasCleared("goodreadsRating"), + goodreadsReviewCount: wasCleared("goodreadsReviewCount"), + hardcoverId: wasCleared("hardcoverId"), + hardcoverRating: wasCleared("hardcoverRating"), + hardcoverReviewCount: wasCleared("hardcoverReviewCount"), + googleId: wasCleared("googleId"), + seriesName: wasCleared("seriesName"), + seriesNumber: wasCleared("seriesNumber"), + seriesTotal: wasCleared("seriesTotal"), + cover: false, }; - return {metadata, clearFlags}; + return { metadata, clearFlags }; } private updateMetadata(shouldLockAllFields: boolean | undefined): void { let metadataUpdateWrapper = this.buildMetadataWrapper(shouldLockAllFields); - this.bookService.updateBookMetadata(this.currentBookId, metadataUpdateWrapper, false).subscribe({ - next: (response) => { - if (shouldLockAllFields !== undefined) { + this.bookService + .updateBookMetadata(this.currentBookId, metadataUpdateWrapper, false) + .subscribe({ + next: (response) => { + if (shouldLockAllFields !== undefined) { + this.messageService.add({ + severity: "success", + summary: shouldLockAllFields + ? "Metadata Locked" + : "Metadata Unlocked", + detail: shouldLockAllFields + ? "All fields have been successfully locked." + : "All fields have been successfully unlocked.", + }); + } + }, + error: () => { this.messageService.add({ - severity: 'success', - summary: shouldLockAllFields ? 'Metadata Locked' : 'Metadata Unlocked', - detail: shouldLockAllFields - ? 'All fields have been successfully locked.' - : 'All fields have been successfully unlocked.', + severity: "error", + summary: "Error", + detail: "Failed to update lock state", }); - } - }, - error: () => { - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to update lock state', - }); - } - }); + }, + }); } getUploadCoverUrl(): string { @@ -513,15 +552,22 @@ export class MetadataEditorComponent implements OnInit { } onUpload(event: FileUploadEvent): void { - const response: HttpResponse = event.originalEvent as HttpResponse; + const response: HttpResponse = + event.originalEvent as HttpResponse; if (response && response.status === 200) { const bookMetadata: BookMetadata = response.body as BookMetadata; - this.bookService.handleBookMetadataUpdate(this.currentBookId, bookMetadata); + this.bookService.handleBookMetadataUpdate( + this.currentBookId, + bookMetadata + ); this.isUploading = false; } else { this.isUploading = false; this.messageService.add({ - severity: 'error', summary: 'Upload Failed', detail: 'An error occurred while uploading the cover', life: 3000 + severity: "error", + summary: "Upload Failed", + detail: "An error occurred while uploading the cover", + life: 3000, }); } } @@ -529,7 +575,10 @@ export class MetadataEditorComponent implements OnInit { onUploadError($event: FileUploadErrorEvent) { this.isUploading = false; this.messageService.add({ - severity: 'error', summary: 'Upload Error', detail: 'An error occurred while uploading the cover', life: 3000 + severity: "error", + summary: "Upload Error", + detail: "An error occurred while uploading the cover", + life: 3000, }); } @@ -537,29 +586,75 @@ export class MetadataEditorComponent implements OnInit { this.bookService.regenerateCover(bookId).subscribe({ next: () => { this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: 'Book cover regenerated successfully. Refresh page to see the new cover.' + severity: "success", + summary: "Success", + detail: + "Book cover regenerated successfully. Refresh page to see the new cover.", }); }, error: () => { this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to start cover regeneration' + severity: "error", + summary: "Error", + detail: "Failed to start cover regeneration", }); - } + }, + }); + } + + // restoreCbxMetadata() { + // this.isLoading = true; + // this.bookService.getComicInfoMetadata(this.currentBookId).subscribe(); + // setTimeout(() => { + // this.isLoading = false; + // // this.refreshingBookIds.delete(bookId); + // }, 10000); + // } + restoreCbxMetadata() { + this.isLoading = true; + console.log("LOADING CBX METADATA FOR BOOK ID:", this.currentBookId); + this.bookService.getComicInfoMetadata(this.currentBookId).subscribe({ + next: (metadata) => { + console.log("Retrieved ComicInfo.xml metadata:", metadata); + + if (metadata) { + this.originalMetadata = structuredClone(metadata); + this.populateFormFromMetadata(metadata); + this.messageService.add({ + severity: "success", + summary: "Restored", + detail: "Metadata loaded from ComicInfo.xml", + }); + } else { + this.messageService.add({ + severity: "warn", + summary: "No Data", + detail: "ComicInfo.xml not found or empty.", + }); + } + this.isLoading = false; + }, + error: (err) => { + console.error("Error loading ComicInfo.xml metadata:", err); + console.error(err.message); + this.isLoading = false; + this.messageService.add({ + severity: "error", + summary: "Error", + detail: err?.error?.message || "Failed to load ComicInfo.xml", + }); + }, }); } restoreMetadata() { this.dialogService.open(MetadataRestoreDialogComponent, { - header: 'Restore Metadata from Backup', + header: "Restore Metadata from Backup", modal: true, closable: true, data: { - bookId: [this.currentBookId] - } + bookId: [this.currentBookId], + }, }); } @@ -592,18 +687,25 @@ export class MetadataEditorComponent implements OnInit { openCoverSearch() { const ref = this.dialogService.open(CoverSearchComponent, { - header: 'Search Cover', + header: "Search Cover", modal: true, closable: true, data: { - bookId: [this.currentBookId] + bookId: [this.currentBookId], }, style: { - width: '90vw', - height: '90vh', - maxWidth: '1200px', - position: 'absolute' + width: "90vw", + height: "90vh", + maxWidth: "1200px", + position: "absolute", }, }); + + ref.onClose.subscribe((result) => { + if (result) { + this.metadataForm.get("thumbnailUrl")?.setValue(result); + this.onSave(); + } + }); } } diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html index ad9c6ca62..18ff79053 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html @@ -527,9 +527,9 @@ [infiniteScrollDistance]="2" [infiniteScrollThrottle]="50" [horizontal]="true"> - @for (book of bookInSeries; track book.id) { + @for (bookInSeriesItem of bookInSeries; track bookInSeriesItem) {
- +
} @@ -548,7 +548,7 @@ [horizontal]="true"> @for (book of recommendedBooks; track book.book.id) {
- +
} diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts index 5c6062fc2..90401dab9 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts @@ -543,7 +543,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges { } goToSeries(seriesName: string): void { - this.handleMetadataClick('series', seriesName); + this.router.navigate(['/series', seriesName]); } goToPublisher(publisher: string): void { diff --git a/booklore-ui/src/app/settings/file-naming-pattern/file-naming-pattern.component.html b/booklore-ui/src/app/settings/file-naming-pattern/file-naming-pattern.component.html index 56eecf671..687f5e155 100644 --- a/booklore-ui/src/app/settings/file-naming-pattern/file-naming-pattern.component.html +++ b/booklore-ui/src/app/settings/file-naming-pattern/file-naming-pattern.component.html @@ -5,7 +5,7 @@ File Naming Patterns

- Define custom naming patterns for uploaded files and for moving files within your library. Use metadata placeholders to automate organization. + Configure automatic file organization using metadata placeholders. Patterns are applied when uploading files, moving files within your library, and after metadata updates.

@@ -17,7 +17,7 @@ Default File Naming Pattern

- Define the default naming pattern for files. This pattern applies to all libraries unless overridden. + This pattern serves as the fallback when no library-specific override is configured.

diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html index 21045a2e2..1ffa82523 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html @@ -7,6 +7,13 @@
+
+ +
+ Network Storage Notice: These features are designed for local file systems and have not been tested with network storage (NAS/cloud). Functionality cannot be guaranteed on network file systems. +
+
+
@@ -23,7 +30,24 @@
-
+
+
+
+ + + +
+

+ + Converts CBR and CB7 files to CBZ format when updating metadata. If disabled, edits to CBR/CB7 files will not be written to the original file. +

+
+
+ +
@@ -40,7 +64,7 @@
-
+
@@ -56,5 +80,21 @@

+ +
+
+
+ + + +
+

+ + Automatically move and rename files according to their library's naming pattern when metadata is updated, either through manual editing or auto-fetch operations. +

+
+
diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.scss b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.scss index 62e8d21c3..7152b9ed2 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.scss +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.scss @@ -46,9 +46,32 @@ padding-bottom: 0; } + &.setting-item-indented { + margin-left: 1.5rem; + padding-left: 1rem; + border-left: 2px solid var(--p-content-border-color); + position: relative; + + &::before { + content: ''; + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 2px; + background: var(--p-primary-color); + opacity: 0.3; + } + } + @media (max-width: 768px) { flex-direction: column; gap: 1rem; + + &.setting-item-indented { + margin-left: 1rem; + padding-left: 0.75rem; + } } } @@ -113,3 +136,23 @@ width: 100%; } } + +.warning-notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + color: var(--p-red-400); + font-size: 0.875rem; + line-height: 1.5; + + .pi-exclamation-triangle { + color: var(--p-red-500); + margin-top: 0.125rem; + flex-shrink: 0; + } + + strong { + font-weight: 600; + color: var(--p-red-500); + } +} diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts index 5a8b6b61e..4ac9be957 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts @@ -20,6 +20,8 @@ export class MetadataPersistenceSettingsComponent implements OnInit { metadataPersistence: MetadataPersistenceSettings = { saveToOriginalFile: false, + convertCbrCb7ToCbz: false, + moveFilesToLibraryPattern: false, backupMetadata: true, backupCover: true }; @@ -62,6 +64,7 @@ export class MetadataPersistenceSettingsComponent implements OnInit { this.metadataPersistence.saveToOriginalFile = !this.metadataPersistence.saveToOriginalFile; if (!this.metadataPersistence.saveToOriginalFile) { + this.metadataPersistence.convertCbrCb7ToCbz = false; this.metadataPersistence.backupMetadata = false; this.metadataPersistence.backupCover = false; } diff --git a/booklore-ui/src/app/stats-component/stats-component.scss b/booklore-ui/src/app/stats-component/stats-component.scss index 1d50a7d83..00d15eb15 100644 --- a/booklore-ui/src/app/stats-component/stats-component.scss +++ b/booklore-ui/src/app/stats-component/stats-component.scss @@ -7,7 +7,7 @@ } .header-card { - background: var(--surface-card, rgba(255, 255, 255, 0.05)); + background: var(--card-background); border-radius: 8px; padding: 25px; margin-bottom: 30px; @@ -196,10 +196,10 @@ color: var(--text-color, #ffffff); padding: 10px 16px; border-radius: 6px; - font-size: 0.9rem; cursor: pointer; transition: all 0.3s ease; display: flex; + font-weight: 500; align-items: center; gap: 8px; flex: 1; @@ -422,7 +422,7 @@ } .chart-section { - background: var(--surface-card, rgba(255, 255, 255, 0.05)); + background: var(--card-background); border-radius: 8px; padding: 15px; backdrop-filter: blur(10px); diff --git a/booklore-ui/src/app/utilities/service/url-helper.service.ts b/booklore-ui/src/app/utilities/service/url-helper.service.ts index 92bd7eb42..f5d4af105 100644 --- a/booklore-ui/src/app/utilities/service/url-helper.service.ts +++ b/booklore-ui/src/app/utilities/service/url-helper.service.ts @@ -40,4 +40,16 @@ export class UrlHelperService { const url = `${this.mediaBaseUrl}/bookdrop/${bookdropId}/cover`; return this.appendToken(url); } + + getBackgroundImageUrl(lastUpdated?: number): string { + let url = `${this.mediaBaseUrl}/background`; + if (lastUpdated) { + url += `?t=${lastUpdated}`; + } + const token = this.getToken(); + if (token) { + url += `${url.includes('?') ? '&' : '?'}token=${token}`; + } + return url; + } } diff --git a/booklore-ui/src/assets/layout/styles/layout/_topbar.scss b/booklore-ui/src/assets/layout/styles/layout/_topbar.scss index afac58f4c..910b33a48 100644 --- a/booklore-ui/src/assets/layout/styles/layout/_topbar.scss +++ b/booklore-ui/src/assets/layout/styles/layout/_topbar.scss @@ -114,14 +114,15 @@ } .config-panel-colors { - > div { + // Only apply flex to color picker rows, not background-center-wrapper + > div:not(.background-center-wrapper) { justify-content: flex-start; padding-top: .5rem; display: flex; gap: .5rem; flex-wrap: wrap; - button { + button:not([pbutton]):not(.p-button) { border: none; width: 1.25rem; height: 1.25rem;