From 17aa633b41b5b5dbe19b47dc66a53f6bc0ccb9be Mon Sep 17 00:00:00 2001 From: acx10 Date: Thu, 29 Jan 2026 17:37:11 -0700 Subject: [PATCH] Backend APIs --- .../filter/AudiobookStreamingJwtFilter.java | 6 - .../filter/EpubStreamingJwtFilter.java | 7 - .../controller/MobileBookController.java | 143 +++++++ .../controller/MobileLibraryController.java | 60 +++ .../controller/MobileShelfController.java | 90 +++++ .../booklore/mobile/dto/MobileBookDetail.java | 17 +- .../booklore/mobile/dto/MobileBookFile.java | 16 + .../mobile/dto/MobileBookSummary.java | 25 ++ .../mobile/dto/MobileLibrarySummary.java | 15 + .../mobile/dto/MobileMagicShelfSummary.java | 16 + .../mobile/dto/MobilePageResponse.java | 33 ++ .../mobile/dto/MobileShelfSummary.java | 16 + .../mobile/dto/UpdateRatingRequest.java | 12 + .../mobile/dto/UpdateStatusRequest.java | 11 + .../mobile/mapper/MobileBookMapper.java | 39 +- .../mobile/service/MobileBookService.java | 382 ++++++++++++++++++ .../MobileBookSpecification.java | 131 ++++++ .../model/dto/BookCompletionHeatmapDto.java | 7 + .../BookCompletionHeatmapResponse.java | 16 + .../model/enums/LibraryOrganizationMode.java | 28 -- .../UserBookFileProgressRepository.java | 13 + .../UserBookProgressRepository.java | 19 + .../service/library/BookGroupingService.java | 60 --- .../service/reader/AudioMetadataService.java | 15 - .../watcher/BookFileTransactionalHandler.java | 26 +- 25 files changed, 1054 insertions(+), 149 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileBookController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileLibraryController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileShelfController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookFile.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookSummary.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileLibrarySummary.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileMagicShelfSummary.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobilePageResponse.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileShelfSummary.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/UpdateRatingRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/UpdateStatusRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/service/MobileBookService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/specification/MobileBookSpecification.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookCompletionHeatmapDto.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookCompletionHeatmapResponse.java diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/AudiobookStreamingJwtFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/AudiobookStreamingJwtFilter.java index 62c749af5..614495d85 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/AudiobookStreamingJwtFilter.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/AudiobookStreamingJwtFilter.java @@ -24,11 +24,6 @@ import java.io.IOException; import java.time.Instant; import java.util.regex.Pattern; -/** - * JWT filter for audiobook streaming endpoints that supports both: - * 1. Authorization header (Bearer token) - for fetch() requests - * 2. Query parameter (token) - for HTML5 audio element compatibility - */ @Component @AllArgsConstructor public class AudiobookStreamingJwtFilter extends OncePerRequestFilter { @@ -52,7 +47,6 @@ public class AudiobookStreamingJwtFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - // Try Authorization header first, then fall back to query parameter String token = extractTokenFromHeader(request); if (token == null) { token = request.getParameter("token"); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/EpubStreamingJwtFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/EpubStreamingJwtFilter.java index 309cd624d..b4337a846 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/EpubStreamingJwtFilter.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/EpubStreamingJwtFilter.java @@ -24,11 +24,6 @@ import java.io.IOException; import java.time.Instant; import java.util.regex.Pattern; -/** - * JWT filter for EPUB streaming endpoints that supports both: - * 1. Authorization header (Bearer token) - for fetch() requests - * 2. Query parameter (token) - for browser-initiated requests (fonts, images in CSS) - */ @Component @AllArgsConstructor public class EpubStreamingJwtFilter extends OncePerRequestFilter { @@ -43,7 +38,6 @@ public class EpubStreamingJwtFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - // Only filter requests to EPUB file streaming endpoint return !EPUB_STREAMING_ENDPOINT_PATTERN.matcher(path).matches(); } @@ -51,7 +45,6 @@ public class EpubStreamingJwtFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - // Try Authorization header first, then fall back to query parameter String token = extractTokenFromHeader(request); if (token == null) { token = request.getParameter("token"); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileBookController.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileBookController.java new file mode 100644 index 000000000..33c4e1243 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileBookController.java @@ -0,0 +1,143 @@ +package com.adityachandel.booklore.mobile.controller; + +import com.adityachandel.booklore.mobile.dto.*; +import com.adityachandel.booklore.mobile.service.MobileBookService; +import com.adityachandel.booklore.model.enums.ReadStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/mobile/v1/books") +@Tag(name = "Mobile Books", description = "Mobile-optimized endpoints for book operations") +public class MobileBookController { + + private final MobileBookService mobileBookService; + + @Operation(summary = "Get paginated book list", + description = "Retrieve a paginated list of books with optional filtering by library, shelf, status, and search text.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Books retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) + @GetMapping + public ResponseEntity> getBooks( + @Parameter(description = "Page number (0-indexed)") @RequestParam(required = false, defaultValue = "0") Integer page, + @Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "20") Integer size, + @Parameter(description = "Sort field (title, addedOn, lastReadTime, seriesName)") @RequestParam(required = false, defaultValue = "addedOn") String sort, + @Parameter(description = "Sort direction (asc, desc)") @RequestParam(required = false, defaultValue = "desc") String dir, + @Parameter(description = "Filter by library ID") @RequestParam(required = false) Long libraryId, + @Parameter(description = "Filter by shelf ID") @RequestParam(required = false) Long shelfId, + @Parameter(description = "Filter by read status") @RequestParam(required = false) ReadStatus status, + @Parameter(description = "Search in title, author, series") @RequestParam(required = false) String search) { + + return ResponseEntity.ok(mobileBookService.getBooks( + page, size, sort, dir, libraryId, shelfId, status, search)); + } + + @Operation(summary = "Get book details", + description = "Retrieve full details for a specific book.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Book details retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Book not found") + }) + @GetMapping("/{bookId}") + public ResponseEntity getBookDetail( + @Parameter(description = "Book ID") @PathVariable Long bookId) { + + return ResponseEntity.ok(mobileBookService.getBookDetail(bookId)); + } + + @Operation(summary = "Search books", + description = "Search books by query text in title, author, and series name.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Search results retrieved successfully"), + @ApiResponse(responseCode = "400", description = "Query parameter required") + }) + @GetMapping("/search") + public ResponseEntity> searchBooks( + @Parameter(description = "Search query", required = true) @RequestParam String q, + @Parameter(description = "Page number (0-indexed)") @RequestParam(required = false, defaultValue = "0") Integer page, + @Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "20") Integer size) { + + return ResponseEntity.ok(mobileBookService.searchBooks(q, page, size)); + } + + @Operation(summary = "Get continue reading list", + description = "Get books currently in progress, sorted by last read time (most recent first).") + @ApiResponse(responseCode = "200", description = "Continue reading list retrieved successfully") + @GetMapping("/continue-reading") + public ResponseEntity> getContinueReading( + @Parameter(description = "Maximum number of books to return") @RequestParam(required = false, defaultValue = "10") Integer limit) { + + return ResponseEntity.ok(mobileBookService.getContinueReading(limit)); + } + + @Operation(summary = "Get recently added books", + description = "Get books added in the last 30 days, sorted by added date (most recent first).") + @ApiResponse(responseCode = "200", description = "Recently added books retrieved successfully") + @GetMapping("/recently-added") + public ResponseEntity> getRecentlyAdded( + @Parameter(description = "Maximum number of books to return") @RequestParam(required = false, defaultValue = "10") Integer limit) { + + return ResponseEntity.ok(mobileBookService.getRecentlyAdded(limit)); + } + + @Operation(summary = "Update book read status", + description = "Update the read status for a book.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Status updated successfully"), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Book not found") + }) + @PutMapping("/{bookId}/status") + public ResponseEntity updateStatus( + @Parameter(description = "Book ID") @PathVariable Long bookId, + @Valid @RequestBody UpdateStatusRequest request) { + + mobileBookService.updateReadStatus(bookId, request.getStatus()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Update book personal rating", + description = "Update the personal rating for a book (1-5).") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Rating updated successfully"), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Book not found") + }) + @PutMapping("/{bookId}/rating") + public ResponseEntity updateRating( + @Parameter(description = "Book ID") @PathVariable Long bookId, + @Valid @RequestBody UpdateRatingRequest request) { + + mobileBookService.updatePersonalRating(bookId, request.getRating()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Get books by magic shelf", + description = "Retrieve a paginated list of books matching a magic shelf's rules.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Books retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Magic shelf not found") + }) + @GetMapping("/magic-shelf/{magicShelfId}") + public ResponseEntity> getBooksByMagicShelf( + @Parameter(description = "Magic shelf ID") @PathVariable Long magicShelfId, + @Parameter(description = "Page number (0-indexed)") @RequestParam(required = false, defaultValue = "0") Integer page, + @Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "20") Integer size) { + + return ResponseEntity.ok(mobileBookService.getBooksByMagicShelf(magicShelfId, page, size)); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileLibraryController.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileLibraryController.java new file mode 100644 index 000000000..10c9c126f --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileLibraryController.java @@ -0,0 +1,60 @@ +package com.adityachandel.booklore.mobile.controller; + +import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.mobile.dto.MobileLibrarySummary; +import com.adityachandel.booklore.mobile.mapper.MobileBookMapper; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.Library; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.LibraryRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.stream.Collectors; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/mobile/v1/libraries") +@Tag(name = "Mobile Libraries", description = "Mobile-optimized endpoints for library operations") +public class MobileLibraryController { + + private final AuthenticationService authenticationService; + private final LibraryRepository libraryRepository; + private final BookRepository bookRepository; + private final MobileBookMapper mobileBookMapper; + + @Operation(summary = "Get user's accessible libraries", + description = "Retrieve a list of libraries the current user has access to.") + @ApiResponse(responseCode = "200", description = "Libraries retrieved successfully") + @GetMapping + public ResponseEntity> getLibraries() { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + + List libraries; + if (user.getPermissions().isAdmin()) { + libraries = libraryRepository.findAll(); + } else { + List libraryIds = user.getAssignedLibraries() != null + ? user.getAssignedLibraries().stream().map(Library::getId).collect(Collectors.toList()) + : List.of(); + libraries = libraryRepository.findByIdIn(libraryIds); + } + + List summaries = libraries.stream() + .map(library -> { + long bookCount = bookRepository.countByLibraryId(library.getId()); + return mobileBookMapper.toLibrarySummary(library, bookCount); + }) + .collect(Collectors.toList()); + + return ResponseEntity.ok(summaries); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileShelfController.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileShelfController.java new file mode 100644 index 000000000..7fabe3984 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileShelfController.java @@ -0,0 +1,90 @@ +package com.adityachandel.booklore.mobile.controller; + +import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.mobile.dto.MobileMagicShelfSummary; +import com.adityachandel.booklore.mobile.dto.MobileShelfSummary; +import com.adityachandel.booklore.mobile.mapper.MobileBookMapper; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.entity.MagicShelfEntity; +import com.adityachandel.booklore.model.entity.ShelfEntity; +import com.adityachandel.booklore.repository.MagicShelfRepository; +import com.adityachandel.booklore.repository.ShelfRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/mobile/v1/shelves") +@Tag(name = "Mobile Shelves", description = "Mobile-optimized endpoints for shelf operations") +public class MobileShelfController { + + private final AuthenticationService authenticationService; + private final ShelfRepository shelfRepository; + private final MagicShelfRepository magicShelfRepository; + private final MobileBookMapper mobileBookMapper; + + @Operation(summary = "Get user's shelves", + description = "Retrieve a list of shelves the current user owns or are public.") + @ApiResponse(responseCode = "200", description = "Shelves retrieved successfully") + @GetMapping + public ResponseEntity> getShelves() { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + + List shelves = shelfRepository.findByUserIdOrPublicShelfTrue(userId); + + List summaries = shelves.stream() + .map(mobileBookMapper::toShelfSummaryFromEntity) + .collect(Collectors.toList()); + + return ResponseEntity.ok(summaries); + } + + @Operation(summary = "Get user's magic shelves", + description = "Retrieve a list of magic shelves the current user owns or are public.") + @ApiResponse(responseCode = "200", description = "Magic shelves retrieved successfully") + @GetMapping("/magic") + public ResponseEntity> getMagicShelves() { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + + // Get user's own magic shelves + List userShelves = magicShelfRepository.findAllByUserId(userId); + + // Get public magic shelves + List publicShelves = magicShelfRepository.findAllByIsPublicIsTrue(); + + // Combine and deduplicate (user's shelves that are also public shouldn't appear twice) + Set seenIds = new HashSet<>(); + List allShelves = new ArrayList<>(); + + for (MagicShelfEntity shelf : userShelves) { + if (seenIds.add(shelf.getId())) { + allShelves.add(shelf); + } + } + for (MagicShelfEntity shelf : publicShelves) { + if (seenIds.add(shelf.getId())) { + allShelves.add(shelf); + } + } + + List summaries = allShelves.stream() + .map(mobileBookMapper::toMagicShelfSummary) + .collect(Collectors.toList()); + + return ResponseEntity.ok(summaries); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookDetail.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookDetail.java index b60400ec0..ab7018fd3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookDetail.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookDetail.java @@ -9,15 +9,10 @@ import java.time.LocalDate; import java.util.List; import java.util.Set; -/** - * Full DTO for book detail view on mobile. - * Contains all fields needed for the book detail screen. - */ @Data @Builder @JsonInclude(JsonInclude.Include.NON_NULL) public class MobileBookDetail { - // Basic fields (same as MobileBookSummary) private Long id; private String title; private List authors; @@ -30,7 +25,6 @@ public class MobileBookDetail { private Instant addedOn; private Instant lastReadTime; - // Additional detail fields private String subtitle; private String description; private Set categories; @@ -48,10 +42,10 @@ public class MobileBookDetail { private List fileTypes; private List files; - // Reading position progress for resume private EpubProgress epubProgress; private PdfProgress pdfProgress; private CbxProgress cbxProgress; + private AudiobookProgress audiobookProgress; @Data @Builder @@ -77,4 +71,13 @@ public class MobileBookDetail { private Float percentage; private Instant updatedAt; } + + @Data + @Builder + public static class AudiobookProgress { + private Long positionMs; + private Integer trackIndex; + private Float percentage; + private Instant updatedAt; + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookFile.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookFile.java new file mode 100644 index 000000000..103ec4bbd --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookFile.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.mobile.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MobileBookFile { + private Long id; + private String bookType; + private Long fileSizeKb; + private String fileName; + private boolean isPrimary; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookSummary.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookSummary.java new file mode 100644 index 000000000..9f1313016 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookSummary.java @@ -0,0 +1,25 @@ +package com.adityachandel.booklore.mobile.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MobileBookSummary { + private Long id; + private String title; + private List authors; + private String thumbnailUrl; + private String readStatus; + private Integer personalRating; + private String seriesName; + private Float seriesNumber; + private Long libraryId; + private Instant addedOn; + private Instant lastReadTime; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileLibrarySummary.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileLibrarySummary.java new file mode 100644 index 000000000..872c61fbd --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileLibrarySummary.java @@ -0,0 +1,15 @@ +package com.adityachandel.booklore.mobile.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MobileLibrarySummary { + private Long id; + private String name; + private String icon; + private long bookCount; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileMagicShelfSummary.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileMagicShelfSummary.java new file mode 100644 index 000000000..bfeffdae0 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileMagicShelfSummary.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.mobile.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MobileMagicShelfSummary { + private Long id; + private String name; + private String icon; + private String iconType; + private boolean publicShelf; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobilePageResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobilePageResponse.java new file mode 100644 index 000000000..51b4301aa --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobilePageResponse.java @@ -0,0 +1,33 @@ +package com.adityachandel.booklore.mobile.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MobilePageResponse { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; + private boolean hasPrevious; + + public static MobilePageResponse of(List content, int page, int size, long totalElements) { + int totalPages = size > 0 ? (int) Math.ceil((double) totalElements / size) : 0; + return MobilePageResponse.builder() + .content(content) + .page(page) + .size(size) + .totalElements(totalElements) + .totalPages(totalPages) + .hasNext(page < totalPages - 1) + .hasPrevious(page > 0) + .build(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileShelfSummary.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileShelfSummary.java new file mode 100644 index 000000000..47f359c7c --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileShelfSummary.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.mobile.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MobileShelfSummary { + private Long id; + private String name; + private String icon; + private int bookCount; + private boolean publicShelf; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/UpdateRatingRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/UpdateRatingRequest.java new file mode 100644 index 000000000..9cec70532 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/UpdateRatingRequest.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.mobile.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Data; + +@Data +public class UpdateRatingRequest { + @Min(value = 1, message = "Rating must be at least 1") + @Max(value = 5, message = "Rating must be at most 5") + private Integer rating; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/UpdateStatusRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/UpdateStatusRequest.java new file mode 100644 index 000000000..7ae6f6311 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/UpdateStatusRequest.java @@ -0,0 +1,11 @@ +package com.adityachandel.booklore.mobile.dto; + +import com.adityachandel.booklore.model.enums.ReadStatus; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class UpdateStatusRequest { + @NotNull(message = "Status is required") + private ReadStatus status; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java index 29d2b7df8..4f25e96ed 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java @@ -7,6 +7,7 @@ import com.adityachandel.booklore.mobile.dto.MobileLibrarySummary; import com.adityachandel.booklore.mobile.dto.MobileMagicShelfSummary; import com.adityachandel.booklore.mobile.dto.MobileShelfSummary; import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.model.enums.BookFileType; import org.mapstruct.*; import java.util.Collections; @@ -60,7 +61,8 @@ public interface MobileBookMapper { @Mapping(target = "epubProgress", source = "progress", qualifiedByName = "mapEpubProgress") @Mapping(target = "pdfProgress", source = "progress", qualifiedByName = "mapPdfProgress") @Mapping(target = "cbxProgress", source = "progress", qualifiedByName = "mapCbxProgress") - MobileBookDetail toDetail(BookEntity book, UserBookProgressEntity progress); + @Mapping(target = "audiobookProgress", source = "fileProgress", qualifiedByName = "mapAudiobookProgress") + MobileBookDetail toDetail(BookEntity book, UserBookProgressEntity progress, UserBookFileProgressEntity fileProgress); @Named("mapAuthors") default List mapAuthors(Set authors) { @@ -118,7 +120,6 @@ public interface MobileBookMapper { if (progress == null) { return null; } - // Try KoReader progress first, then Kobo progress if (progress.getKoreaderProgressPercent() != null) { return progress.getKoreaderProgressPercent(); } @@ -165,6 +166,40 @@ public interface MobileBookMapper { .build(); } + @Named("mapAudiobookProgress") + default MobileBookDetail.AudiobookProgress mapAudiobookProgress(UserBookFileProgressEntity fileProgress) { + if (fileProgress == null) return null; + if (fileProgress.getBookFile() == null || + fileProgress.getBookFile().getBookType() != BookFileType.AUDIOBOOK) { + return null; + } + + return MobileBookDetail.AudiobookProgress.builder() + .positionMs(parseLongOrNull(fileProgress.getPositionData())) + .trackIndex(parseIntOrNull(fileProgress.getPositionHref())) + .percentage(fileProgress.getProgressPercent()) + .updatedAt(fileProgress.getLastReadTime()) + .build(); + } + + default Long parseLongOrNull(String value) { + if (value == null) return null; + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return null; + } + } + + default Integer parseIntOrNull(String value) { + if (value == null) return null; + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + } + @Named("mapPrimaryFileType") default String mapPrimaryFileType(BookEntity book) { if (book == null) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/service/MobileBookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/service/MobileBookService.java new file mode 100644 index 000000000..1f03dac37 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/service/MobileBookService.java @@ -0,0 +1,382 @@ +package com.adityachandel.booklore.mobile.service; + +import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.mobile.dto.*; +import com.adityachandel.booklore.mobile.mapper.MobileBookMapper; +import com.adityachandel.booklore.mobile.specification.MobileBookSpecification; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.Library; +import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.model.enums.ReadStatus; +import com.adityachandel.booklore.repository.*; +import com.adityachandel.booklore.service.opds.MagicShelfBookService; +import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@AllArgsConstructor +public class MobileBookService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + + private final BookRepository bookRepository; + private final UserBookProgressRepository userBookProgressRepository; + private final UserBookFileProgressRepository userBookFileProgressRepository; + private final ShelfRepository shelfRepository; + private final AuthenticationService authenticationService; + private final MobileBookMapper mobileBookMapper; + private final MagicShelfBookService magicShelfBookService; + + @Transactional(readOnly = true) + public MobilePageResponse getBooks( + Integer page, + Integer size, + String sortBy, + String sortDir, + Long libraryId, + Long shelfId, + ReadStatus status, + String search) { + + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + Set accessibleLibraryIds = getAccessibleLibraryIds(user); + + int pageNum = page != null && page >= 0 ? page : 0; + int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE; + + Sort sort = buildSort(sortBy, sortDir); + Pageable pageable = PageRequest.of(pageNum, pageSize, sort); + + Specification spec = buildSpecification( + accessibleLibraryIds, libraryId, shelfId, status, search, userId); + + Page bookPage = bookRepository.findAll(spec, pageable); + + Set bookIds = bookPage.getContent().stream() + .map(BookEntity::getId) + .collect(Collectors.toSet()); + Map progressMap = getProgressMap(userId, bookIds); + + List summaries = bookPage.getContent().stream() + .map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId()))) + .collect(Collectors.toList()); + + return MobilePageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements()); + } + + @Transactional(readOnly = true) + public MobileBookDetail getBookDetail(Long bookId) { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + Set accessibleLibraryIds = getAccessibleLibraryIds(user); + + BookEntity book = bookRepository.findByIdWithBookFiles(bookId) + .orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + + if (accessibleLibraryIds != null && !accessibleLibraryIds.contains(book.getLibrary().getId())) { + throw ApiError.FORBIDDEN.createException("Access denied to this book"); + } + + UserBookProgressEntity progress = userBookProgressRepository + .findByUserIdAndBookId(userId, bookId) + .orElse(null); + + UserBookFileProgressEntity fileProgress = userBookFileProgressRepository + .findMostRecentAudiobookProgressByUserIdAndBookId(userId, bookId) + .orElse(null); + + return mobileBookMapper.toDetail(book, progress, fileProgress); + } + + @Transactional(readOnly = true) + public MobilePageResponse searchBooks( + String query, + Integer page, + Integer size) { + + if (query == null || query.trim().isEmpty()) { + throw ApiError.INVALID_QUERY_PARAMETERS.createException(); + } + + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + Set accessibleLibraryIds = getAccessibleLibraryIds(user); + + int pageNum = page != null && page >= 0 ? page : 0; + int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE; + + Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, "addedOn")); + + Specification spec = MobileBookSpecification.combine( + MobileBookSpecification.notDeleted(), + MobileBookSpecification.hasDigitalFile(), + MobileBookSpecification.inLibraries(accessibleLibraryIds), + MobileBookSpecification.searchText(query) + ); + + Page bookPage = bookRepository.findAll(spec, pageable); + + Set bookIds = bookPage.getContent().stream() + .map(BookEntity::getId) + .collect(Collectors.toSet()); + Map progressMap = getProgressMap(userId, bookIds); + + List summaries = bookPage.getContent().stream() + .map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId()))) + .collect(Collectors.toList()); + + return MobilePageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements()); + } + + @Transactional(readOnly = true) + public List getContinueReading(Integer limit) { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + Set accessibleLibraryIds = getAccessibleLibraryIds(user); + + int maxItems = limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : 10; + + Specification spec = MobileBookSpecification.combine( + MobileBookSpecification.notDeleted(), + MobileBookSpecification.hasDigitalFile(), + MobileBookSpecification.inLibraries(accessibleLibraryIds), + MobileBookSpecification.inProgress(userId) + ); + + List books = bookRepository.findAll(spec); + + Set bookIds = books.stream() + .map(BookEntity::getId) + .collect(Collectors.toSet()); + Map progressMap = getProgressMap(userId, bookIds); + + return books.stream() + .filter(book -> progressMap.containsKey(book.getId())) + .sorted((b1, b2) -> { + Instant t1 = progressMap.get(b1.getId()).getLastReadTime(); + Instant t2 = progressMap.get(b2.getId()).getLastReadTime(); + if (t1 == null && t2 == null) return 0; + if (t1 == null) return 1; + if (t2 == null) return -1; + return t2.compareTo(t1); + }) + .limit(maxItems) + .map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId()))) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getRecentlyAdded(Integer limit) { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + Set accessibleLibraryIds = getAccessibleLibraryIds(user); + + int maxItems = limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : 10; + + Specification spec = MobileBookSpecification.combine( + MobileBookSpecification.notDeleted(), + MobileBookSpecification.hasDigitalFile(), + MobileBookSpecification.inLibraries(accessibleLibraryIds), + MobileBookSpecification.addedWithinDays(30) + ); + + Pageable pageable = PageRequest.of(0, maxItems, Sort.by(Sort.Direction.DESC, "addedOn")); + Page bookPage = bookRepository.findAll(spec, pageable); + + Set bookIds = bookPage.getContent().stream() + .map(BookEntity::getId) + .collect(Collectors.toSet()); + Map progressMap = getProgressMap(userId, bookIds); + + return bookPage.getContent().stream() + .map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId()))) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public MobilePageResponse getBooksByMagicShelf( + Long magicShelfId, + Integer page, + Integer size) { + + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + + int pageNum = page != null && page >= 0 ? page : 0; + int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE; + + var booksPage = magicShelfBookService.getBooksByMagicShelfId(userId, magicShelfId, pageNum, pageSize); + + Set bookIds = booksPage.getContent().stream() + .map(book -> book.getId()) + .collect(Collectors.toSet()); + Map progressMap = getProgressMap(userId, bookIds); + + List summaries = booksPage.getContent().stream() + .map(book -> { + BookEntity bookEntity = bookRepository.findById(book.getId()).orElse(null); + if (bookEntity == null) { + return null; + } + if (bookEntity.getIsPhysical() != null && bookEntity.getIsPhysical()) { + return null; + } + return mobileBookMapper.toSummary(bookEntity, progressMap.get(book.getId())); + }) + .filter(summary -> summary != null) + .collect(Collectors.toList()); + + return MobilePageResponse.of(summaries, pageNum, pageSize, booksPage.getTotalElements()); + } + + @Transactional + public void updateReadStatus(Long bookId, ReadStatus status) { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + Set accessibleLibraryIds = getAccessibleLibraryIds(user); + + BookEntity book = bookRepository.findById(bookId) + .orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + + if (!accessibleLibraryIds.contains(book.getLibrary().getId())) { + throw ApiError.FORBIDDEN.createException("Access denied to this book"); + } + + UserBookProgressEntity progress = userBookProgressRepository + .findByUserIdAndBookId(userId, bookId) + .orElseGet(() -> createNewProgress(userId, book)); + + progress.setReadStatus(status); + progress.setReadStatusModifiedTime(Instant.now()); + + if (status == ReadStatus.READ && progress.getDateFinished() == null) { + progress.setDateFinished(Instant.now()); + } + + userBookProgressRepository.save(progress); + } + + @Transactional + public void updatePersonalRating(Long bookId, Integer rating) { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + Set accessibleLibraryIds = getAccessibleLibraryIds(user); + + BookEntity book = bookRepository.findById(bookId) + .orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + + if (!accessibleLibraryIds.contains(book.getLibrary().getId())) { + throw ApiError.FORBIDDEN.createException("Access denied to this book"); + } + + UserBookProgressEntity progress = userBookProgressRepository + .findByUserIdAndBookId(userId, bookId) + .orElseGet(() -> createNewProgress(userId, book)); + + progress.setPersonalRating(rating); + userBookProgressRepository.save(progress); + } + + private UserBookProgressEntity createNewProgress(Long userId, BookEntity book) { + return UserBookProgressEntity.builder() + .user(BookLoreUserEntity.builder().id(userId).build()) + .book(book) + .build(); + } + + private Set getAccessibleLibraryIds(BookLoreUser user) { + if (user.getPermissions().isAdmin()) { + return null; + } + if (user.getAssignedLibraries() == null || user.getAssignedLibraries().isEmpty()) { + return Collections.emptySet(); + } + return user.getAssignedLibraries().stream() + .map(Library::getId) + .collect(Collectors.toSet()); + } + + private Map getProgressMap(Long userId, Set bookIds) { + if (bookIds.isEmpty()) { + return Collections.emptyMap(); + } + return userBookProgressRepository.findByUserIdAndBookIdIn(userId, bookIds).stream() + .collect(Collectors.toMap( + p -> p.getBook().getId(), + Function.identity() + )); + } + + private Specification buildSpecification( + Set accessibleLibraryIds, + Long libraryId, + Long shelfId, + ReadStatus status, + String search, + Long userId) { + + List> specs = new ArrayList<>(); + specs.add(MobileBookSpecification.notDeleted()); + specs.add(MobileBookSpecification.hasDigitalFile()); + + if (accessibleLibraryIds != null) { + if (libraryId != null && accessibleLibraryIds.contains(libraryId)) { + specs.add(MobileBookSpecification.inLibrary(libraryId)); + } else if (libraryId != null) { + throw ApiError.FORBIDDEN.createException("Access denied to library " + libraryId); + } else { + specs.add(MobileBookSpecification.inLibraries(accessibleLibraryIds)); + } + } else if (libraryId != null) { + specs.add(MobileBookSpecification.inLibrary(libraryId)); + } + + if (shelfId != null) { + ShelfEntity shelf = shelfRepository.findById(shelfId) + .orElseThrow(() -> ApiError.SHELF_NOT_FOUND.createException(shelfId)); + if (!shelf.isPublic() && !shelf.getUser().getId().equals(userId)) { + throw ApiError.FORBIDDEN.createException("Access denied to shelf " + shelfId); + } + specs.add(MobileBookSpecification.inShelf(shelfId)); + } + + if (status != null) { + specs.add(MobileBookSpecification.withReadStatus(status, userId)); + } + + if (search != null && !search.trim().isEmpty()) { + specs.add(MobileBookSpecification.searchText(search)); + } + + return MobileBookSpecification.combine(specs.toArray(new Specification[0])); + } + + private Sort buildSort(String sortBy, String sortDir) { + Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) + ? Sort.Direction.ASC + : Sort.Direction.DESC; + + String field = switch (sortBy != null ? sortBy.toLowerCase() : "") { + case "title" -> "metadata.title"; + case "seriesname", "series" -> "metadata.seriesName"; + case "lastreadtime" -> "addedOn"; + default -> "addedOn"; + }; + + return Sort.by(direction, field); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/specification/MobileBookSpecification.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/specification/MobileBookSpecification.java new file mode 100644 index 000000000..62853711c --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/specification/MobileBookSpecification.java @@ -0,0 +1,131 @@ +package com.adityachandel.booklore.mobile.specification; + +import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.model.enums.ReadStatus; +import jakarta.persistence.criteria.*; +import org.springframework.data.jpa.domain.Specification; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class MobileBookSpecification { + + private MobileBookSpecification() { + } + + public static Specification inLibraries(Collection libraryIds) { + return (root, query, cb) -> { + if (libraryIds == null || libraryIds.isEmpty()) { + return cb.conjunction(); + } + return root.get("library").get("id").in(libraryIds); + }; + } + + public static Specification inLibrary(Long libraryId) { + return (root, query, cb) -> { + if (libraryId == null) { + return cb.conjunction(); + } + return cb.equal(root.get("library").get("id"), libraryId); + }; + } + + public static Specification inShelf(Long shelfId) { + return (root, query, cb) -> { + if (shelfId == null) { + return cb.conjunction(); + } + Join shelvesJoin = root.join("shelves", JoinType.INNER); + return cb.equal(shelvesJoin.get("id"), shelfId); + }; + } + + public static Specification withReadStatus(ReadStatus status, Long userId) { + return (root, query, cb) -> { + if (status == null || userId == null) { + return cb.conjunction(); + } + Subquery subquery = query.subquery(Long.class); + Root progressRoot = subquery.from(UserBookProgressEntity.class); + subquery.select(progressRoot.get("book").get("id")) + .where( + cb.equal(progressRoot.get("user").get("id"), userId), + cb.equal(progressRoot.get("readStatus"), status) + ); + return root.get("id").in(subquery); + }; + } + + public static Specification inProgress(Long userId) { + return (root, query, cb) -> { + if (userId == null) { + return cb.conjunction(); + } + Subquery subquery = query.subquery(Long.class); + Root progressRoot = subquery.from(UserBookProgressEntity.class); + subquery.select(progressRoot.get("book").get("id")) + .where( + cb.equal(progressRoot.get("user").get("id"), userId), + progressRoot.get("readStatus").in(ReadStatus.READING, ReadStatus.RE_READING) + ); + return root.get("id").in(subquery); + }; + } + + public static Specification addedWithinDays(int days) { + return (root, query, cb) -> { + Instant cutoff = Instant.now().minus(days, ChronoUnit.DAYS); + return cb.greaterThanOrEqualTo(root.get("addedOn"), cutoff); + }; + } + + public static Specification searchText(String searchQuery) { + return (root, query, cb) -> { + if (searchQuery == null || searchQuery.trim().isEmpty()) { + return cb.conjunction(); + } + String pattern = "%" + searchQuery.toLowerCase().trim() + "%"; + + Join metadataJoin = root.join("metadata", JoinType.LEFT); + Join authorsJoin = metadataJoin.join("authors", JoinType.LEFT); + + List predicates = new ArrayList<>(); + predicates.add(cb.like(cb.lower(metadataJoin.get("title")), pattern)); + predicates.add(cb.like(cb.lower(metadataJoin.get("seriesName")), pattern)); + predicates.add(cb.like(cb.lower(authorsJoin.get("name")), pattern)); + + query.distinct(true); + + return cb.or(predicates.toArray(new Predicate[0])); + }; + } + + public static Specification notDeleted() { + return (root, query, cb) -> cb.or( + cb.isNull(root.get("deleted")), + cb.equal(root.get("deleted"), false) + ); + } + + public static Specification hasDigitalFile() { + return (root, query, cb) -> cb.or( + cb.isNull(root.get("isPhysical")), + cb.equal(root.get("isPhysical"), false) + ); + } + + @SafeVarargs + public static Specification combine(Specification... specs) { + Specification result = (root, query, cb) -> cb.conjunction(); + for (Specification spec : specs) { + if (spec != null) { + result = result.and(spec); + } + } + return result; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookCompletionHeatmapDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookCompletionHeatmapDto.java new file mode 100644 index 000000000..496b9ac0e --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookCompletionHeatmapDto.java @@ -0,0 +1,7 @@ +package com.adityachandel.booklore.model.dto; + +public interface BookCompletionHeatmapDto { + Integer getYear(); + Integer getMonth(); + Long getCount(); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookCompletionHeatmapResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookCompletionHeatmapResponse.java new file mode 100644 index 000000000..34a6e78f2 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookCompletionHeatmapResponse.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookCompletionHeatmapResponse { + private Integer year; + private Integer month; + private Long count; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/LibraryOrganizationMode.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/LibraryOrganizationMode.java index 4149765af..25bc6c350 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/LibraryOrganizationMode.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/LibraryOrganizationMode.java @@ -1,34 +1,6 @@ package com.adityachandel.booklore.model.enums; -/** - * Defines how a library organizes book files into folders. - * This affects how files are grouped together as a single book. - */ public enum LibraryOrganizationMode { - - /** - * Each book has its own dedicated folder. - * All files within a folder are treated as formats of the same book. - * Simple and deterministic - no fuzzy matching needed. - *

- * Example: - *

-     * Library/
-     * └── American Gods/
-     *     ├── American Gods.epub
-     *     ├── American Gods.m4b
-     *     └── American Gods - 10th Anniversary.pdf
-     * 
- * All three files become one book. - */ BOOK_PER_FOLDER, - - /** - * System automatically detects grouping using folder-centric fuzzy matching. - * Uses folder name as reference, applies similarity matching for variations. - * Handles series detection to keep numbered entries separate. - *

- * Use this when your library has mixed organization or you're unsure. - */ AUTO_DETECT } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookFileProgressRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookFileProgressRepository.java index d94dc95d6..f1c8180b6 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookFileProgressRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookFileProgressRepository.java @@ -36,6 +36,19 @@ public interface UserBookFileProgressRepository extends JpaRepository findMostRecentAudiobookProgressByUserIdAndBookId( + @Param("userId") Long userId, + @Param("bookId") Long bookId + ); + @Query(""" SELECT ubfp FROM UserBookFileProgressEntity ubfp WHERE ubfp.user.id = :userId diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookProgressRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookProgressRepository.java index 0a3116c9b..17dad5bab 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookProgressRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookProgressRepository.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.repository; +import com.adityachandel.booklore.model.dto.BookCompletionHeatmapDto; import com.adityachandel.booklore.model.dto.CompletionTimelineDto; import com.adityachandel.booklore.model.entity.UserBookProgressEntity; import org.springframework.data.jpa.repository.JpaRepository; @@ -138,4 +139,22 @@ public interface UserBookProgressRepository extends JpaRepository bookIds, @Param("rating") Integer rating); + + @Query(""" + SELECT + YEAR(ubp.dateFinished) as year, + MONTH(ubp.dateFinished) as month, + COUNT(ubp) as count + FROM UserBookProgressEntity ubp + WHERE ubp.user.id = :userId + AND ubp.dateFinished IS NOT NULL + AND YEAR(ubp.dateFinished) >= :startYear + AND YEAR(ubp.dateFinished) <= :endYear + GROUP BY YEAR(ubp.dateFinished), MONTH(ubp.dateFinished) + ORDER BY year ASC, month ASC + """) + List findBookCompletionHeatmap( + @Param("userId") Long userId, + @Param("startYear") int startYear, + @Param("endYear") int endYear); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookGroupingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookGroupingService.java index b888d450e..1413956cc 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookGroupingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/library/BookGroupingService.java @@ -13,11 +13,6 @@ import org.springframework.stereotype.Service; import java.util.*; -/** - * Unified service for grouping book files. - * Handles both initial scan (grouping new files) and rescan (matching to existing books). - * Organization mode determines the grouping strategy. - */ @Service @Slf4j @RequiredArgsConstructor @@ -27,23 +22,11 @@ public class BookGroupingService { private final BookRepository bookRepository; - /** - * Result of grouping operation for rescan. - * Contains files to attach to existing books and files that need new book entities. - */ public record GroupingResult( Map> filesToAttach, Map> newBookGroups ) {} - /** - * Groups new files for initial library scan. - * Returns groups of files that should become a single book entity. - * - * @param newFiles files to group - * @param libraryEntity the library being processed - * @return map of group key to list of files in that group - */ public Map> groupForInitialScan(List newFiles, LibraryEntity libraryEntity) { LibraryOrganizationMode mode = getOrganizationMode(libraryEntity); @@ -53,14 +36,6 @@ public class BookGroupingService { }; } - /** - * Groups new files for rescan, considering existing books. - * Returns files to attach to existing books and new groups to create. - * - * @param newFiles files to process - * @param libraryEntity the library being processed - * @return grouping result with attach map and new groups - */ public GroupingResult groupForRescan(List newFiles, LibraryEntity libraryEntity) { LibraryOrganizationMode mode = getOrganizationMode(libraryEntity); @@ -76,7 +51,6 @@ public class BookGroupingService { } } - // Group unmatched files into new book groups Map> newBookGroups; if (unmatched.isEmpty()) { newBookGroups = Collections.emptyMap(); @@ -90,9 +64,6 @@ public class BookGroupingService { return new GroupingResult(filesToAttach, newBookGroups); } - /** - * Simple folder-based grouping: all files in the same folder become one book. - */ private Map> groupByFolder(List files) { Map> result = new LinkedHashMap<>(); @@ -106,12 +77,7 @@ public class BookGroupingService { return result; } - /** - * Finds an existing book that matches the given file. - * Strategy depends on organization mode. - */ private BookEntity findMatchingBook(LibraryFile file, LibraryOrganizationMode mode) { - // First check for fileless books by metadata matching BookEntity filelessMatch = findMatchingFilelessBook(file, file.getLibraryEntity()); if (filelessMatch != null) { return filelessMatch; @@ -119,7 +85,6 @@ public class BookGroupingService { String fileSubPath = file.getFileSubPath(); - // Root-level files: don't auto-attach if (fileSubPath == null || fileSubPath.isEmpty()) { return null; } @@ -127,7 +92,6 @@ public class BookGroupingService { Long libraryPathId = file.getLibraryPathEntity().getId(); List booksInDirectory = bookRepository.findAllByLibraryPathIdAndFileSubPath(libraryPathId, fileSubPath); - // Filter to non-deleted books only List activeBooksInDirectory = booksInDirectory.stream() .filter(book -> book.getDeleted() == null || !book.getDeleted()) .toList(); @@ -142,10 +106,6 @@ public class BookGroupingService { }; } - /** - * Finds a fileless book that matches the given file by metadata title. - * Only matches books that either have no libraryPath or have the same libraryPath as the file. - */ private BookEntity findMatchingFilelessBook(LibraryFile file, LibraryEntity library) { List filelessBooks = bookRepository.findFilelessBooksByLibraryId(library.getId()); if (filelessBooks.isEmpty()) { @@ -156,7 +116,6 @@ public class BookGroupingService { Long fileLibraryPathId = file.getLibraryPathEntity().getId(); for (BookEntity book : filelessBooks) { - // Skip books that already have a different libraryPath if (book.getLibraryPath() != null && !book.getLibraryPath().getId().equals(fileLibraryPathId)) { continue; } @@ -174,12 +133,7 @@ public class BookGroupingService { return null; } - /** - * BOOK_PER_FOLDER: If exactly one book in folder, attach to it. - * If multiple books exist (edge case), use filename matching. - */ private BookEntity findMatchBookPerFolder(LibraryFile file, List booksInDirectory) { - // Filter to books with files for filename-based matching List booksWithFiles = booksInDirectory.stream() .filter(BookEntity::hasFiles) .toList(); @@ -195,18 +149,12 @@ public class BookGroupingService { return null; } - // Multiple books in folder - shouldn't happen with BOOK_PER_FOLDER mode - // Fall back to exact filename matching log.warn("BOOK_PER_FOLDER: Multiple books ({}) in folder '{}', using filename match", booksWithFiles.size(), file.getFileSubPath()); return findExactMatch(file, booksWithFiles); } - /** - * AUTO_DETECT: Use fuzzy matching to find best match. - */ private BookEntity findMatchAutoDetect(LibraryFile file, List booksInDirectory) { - // Filter to books with files for filename-based matching List booksWithFiles = booksInDirectory.stream() .filter(BookEntity::hasFiles) .toList(); @@ -215,7 +163,6 @@ public class BookGroupingService { return null; } - // If exactly one book with files in folder, attach to it if (booksWithFiles.size() == 1) { BookEntity book = booksWithFiles.get(0); log.debug("AUTO_DETECT: Single book in folder '{}', attaching '{}' to '{}'", @@ -223,7 +170,6 @@ public class BookGroupingService { return book; } - // Multiple books: use fuzzy matching String fileGroupingKey = BookFileGroupingUtils.extractGroupingKey(file.getFileName()); BookEntity bestMatch = null; double bestSimilarity = 0; @@ -232,12 +178,10 @@ public class BookGroupingService { BookFileEntity primaryFile = book.getPrimaryBookFile(); String existingGroupingKey = BookFileGroupingUtils.extractGroupingKey(primaryFile.getFileName()); - // Exact match if (fileGroupingKey.equals(existingGroupingKey)) { return book; } - // Track best fuzzy match double similarity = BookFileGroupingUtils.calculateSimilarity(fileGroupingKey, existingGroupingKey); if (similarity >= 0.85 && similarity > bestSimilarity) { bestSimilarity = similarity; @@ -252,10 +196,6 @@ public class BookGroupingService { return bestMatch; } - /** - * Finds a book with exact filename match. - * Assumes books list contains only books with files. - */ private BookEntity findExactMatch(LibraryFile file, List books) { String fileKey = BookFileGroupingUtils.extractGroupingKey(file.getFileName()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/AudioMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/AudioMetadataService.java index 9e78b8abe..d6789bf68 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/AudioMetadataService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/AudioMetadataService.java @@ -28,10 +28,6 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; -/** - * Service for extracting and caching audiobook metadata. - * Handles single-file (M4B) and folder-based audiobooks. - */ @Slf4j @Service @RequiredArgsConstructor @@ -54,17 +50,11 @@ public class AudioMetadataService { } } - /** - * Get audiobook metadata, using cache if available and valid. - */ public AudiobookInfo getMetadata(BookFileEntity bookFile, Path audioPath) throws Exception { CachedAudiobookMetadata metadata = getCachedMetadata(bookFile, audioPath); return metadata.info; } - /** - * Extract embedded cover art from an audio file. - */ public byte[] getEmbeddedCoverArt(Path audioPath) { try { AudioFile audioFile = AudioFileIO.read(audioPath.toFile()); @@ -81,9 +71,6 @@ public class AudioMetadataService { return null; } - /** - * Get the MIME type of embedded cover art. - */ public String getCoverArtMimeType(Path audioPath) { try { AudioFile audioFile = AudioFileIO.read(audioPath.toFile()); @@ -95,7 +82,6 @@ public class AudioMetadataService { if (mimeType != null && !mimeType.isEmpty()) { return mimeType; } - // Fallback based on binary data magic bytes byte[] data = artwork.getBinaryData(); if (data != null && data.length > 2) { if (data[0] == (byte) 0xFF && data[1] == (byte) 0xD8) { @@ -416,7 +402,6 @@ public class AudioMetadataService { return value; } } catch (Exception e) { - // Field not supported for this tag type } } return null; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFileTransactionalHandler.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFileTransactionalHandler.java index 88a5e7096..6e4912003 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFileTransactionalHandler.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFileTransactionalHandler.java @@ -59,21 +59,17 @@ public class BookFileTransactionalHandler { LibraryPathEntity libraryPathEntity = bookFilePersistenceService.getLibraryPathEntityForFile(libraryEntity, libraryPath); String fileSubPath = FileUtils.getRelativeSubPath(libraryPathEntity.getPath(), path); - // Check if this is a moved file by looking for existing book with same content hash String currentHash = FileFingerprint.generateHash(path); Optional existingByHash = bookRepository.findByCurrentHash(currentHash); if (existingByHash.isPresent()) { - // File was moved - update the existing book's path instead of creating a duplicate bookFilePersistenceService.updatePathIfChanged(existingByHash.get(), libraryEntity, path, currentHash); log.info("[CREATE] File '{}' recognized as moved file, updated existing book's path", filePath); notificationService.sendMessageToPermissions(Topic.LOG, LogNotification.info("Finished processing file: " + filePath), Set.of(ADMIN, MANAGE_LIBRARY)); return; } - // Check for fileless books that match by metadata BookEntity filelessMatch = findMatchingFilelessBook(libraryEntity, fileName, libraryPathEntity); if (filelessMatch != null) { - // Set libraryPath if not set (fileless books like physical books don't have one) if (filelessMatch.getLibraryPath() == null) { filelessMatch.setLibraryPath(libraryPathEntity); bookRepository.save(filelessMatch); @@ -121,7 +117,6 @@ public class BookFileTransactionalHandler { LibraryPathEntity libraryPathEntity = bookFilePersistenceService.getLibraryPathEntityForFile(libraryEntity, libraryPath); String fileSubPath = FileUtils.getRelativeSubPath(libraryPathEntity.getPath(), folderPath); - // For folder-based audiobooks, try to match with book in parent folder BookEntity matchingBook = findMatchingBookForFolderAudiobook(libraryPathEntity.getId(), fileSubPath, folderName); if (matchingBook != null) { @@ -148,16 +143,11 @@ public class BookFileTransactionalHandler { private static final double FUZZY_MATCH_THRESHOLD = 0.85; - /** - * Finds a fileless book that matches the given file by metadata title. - * Only matches books that either have no libraryPath or have the same libraryPath as the file. - */ private BookEntity findMatchingFilelessBook(LibraryEntity library, String fileName, LibraryPathEntity fileLibraryPath) { List filelessBooks = bookRepository.findFilelessBooksByLibraryId(library.getId()); String fileBaseName = BookFileGroupingUtils.extractGroupingKey(fileName); for (BookEntity book : filelessBooks) { - // Skip books that already have a different libraryPath if (book.getLibraryPath() != null && !book.getLibraryPath().getId().equals(fileLibraryPath.getId())) { continue; } @@ -174,7 +164,6 @@ public class BookFileTransactionalHandler { } private BookEntity findMatchingBook(Long libraryPathId, String fileSubPath, String fileName) { - // Skip root-level files if (fileSubPath == null || fileSubPath.isEmpty()) { return null; } @@ -192,16 +181,14 @@ public class BookFileTransactionalHandler { } BookFileEntity primaryFile = book.getPrimaryBookFile(); if (primaryFile == null) { - continue; // Skip fileless books + continue; } String existingGroupingKey = BookFileGroupingUtils.extractGroupingKey(primaryFile.getFileName()); - // Try exact match first if (fileGroupingKey.equals(existingGroupingKey)) { return book; } - // Track best fuzzy match double similarity = BookFileGroupingUtils.calculateSimilarity(fileGroupingKey, existingGroupingKey); if (similarity >= FUZZY_MATCH_THRESHOLD && similarity > bestSimilarity) { bestSimilarity = similarity; @@ -209,7 +196,6 @@ public class BookFileTransactionalHandler { } } - // Return fuzzy match if found if (fuzzyMatch != null) { String primaryFileName = fuzzyMatch.hasFiles() ? fuzzyMatch.getPrimaryBookFile().getFileName() : "book#" + fuzzyMatch.getId(); log.debug("Fuzzy matched '{}' to '{}' with similarity {}", fileName, primaryFileName, bestSimilarity); @@ -217,12 +203,7 @@ public class BookFileTransactionalHandler { return fuzzyMatch; } - /** - * Find matching book for a folder-based audiobook. - * Looks in the parent folder since audiobook folders are typically nested inside book folders. - */ private BookEntity findMatchingBookForFolderAudiobook(Long libraryPathId, String fileSubPath, String folderName) { - // Get parent folder path for matching String parentPath = Optional.ofNullable(fileSubPath) .filter(p -> !p.isEmpty()) .map(p -> { @@ -236,7 +217,6 @@ public class BookFileTransactionalHandler { String folderGroupingKey = BookFileGroupingUtils.extractGroupingKey(folderName); - // Search in parent folder List booksInParent = bookRepository.findAllByLibraryPathIdAndFileSubPath(libraryPathId, parentPath); BookEntity fuzzyMatch = null; @@ -248,16 +228,14 @@ public class BookFileTransactionalHandler { } BookFileEntity primaryFile = book.getPrimaryBookFile(); if (primaryFile == null) { - continue; // Skip fileless books + continue; } String existingGroupingKey = BookFileGroupingUtils.extractGroupingKey(primaryFile.getFileName()); - // Try exact match first if (folderGroupingKey.equals(existingGroupingKey)) { return book; } - // Track best fuzzy match double similarity = BookFileGroupingUtils.calculateSimilarity(folderGroupingKey, existingGroupingKey); if (similarity >= FUZZY_MATCH_THRESHOLD && similarity > bestSimilarity) { bestSimilarity = similarity;