mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Backend APIs
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<MobilePageResponse<MobileBookSummary>> 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<MobileBookDetail> 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<MobilePageResponse<MobileBookSummary>> 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<List<MobileBookSummary>> 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<List<MobileBookSummary>> 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<Void> 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<Void> 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<MobilePageResponse<MobileBookSummary>> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<List<MobileLibrarySummary>> getLibraries() {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
|
||||
List<LibraryEntity> libraries;
|
||||
if (user.getPermissions().isAdmin()) {
|
||||
libraries = libraryRepository.findAll();
|
||||
} else {
|
||||
List<Long> libraryIds = user.getAssignedLibraries() != null
|
||||
? user.getAssignedLibraries().stream().map(Library::getId).collect(Collectors.toList())
|
||||
: List.of();
|
||||
libraries = libraryRepository.findByIdIn(libraryIds);
|
||||
}
|
||||
|
||||
List<MobileLibrarySummary> summaries = libraries.stream()
|
||||
.map(library -> {
|
||||
long bookCount = bookRepository.countByLibraryId(library.getId());
|
||||
return mobileBookMapper.toLibrarySummary(library, bookCount);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
}
|
||||
@@ -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<List<MobileShelfSummary>> getShelves() {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
|
||||
List<ShelfEntity> shelves = shelfRepository.findByUserIdOrPublicShelfTrue(userId);
|
||||
|
||||
List<MobileShelfSummary> 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<List<MobileMagicShelfSummary>> getMagicShelves() {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
|
||||
// Get user's own magic shelves
|
||||
List<MagicShelfEntity> userShelves = magicShelfRepository.findAllByUserId(userId);
|
||||
|
||||
// Get public magic shelves
|
||||
List<MagicShelfEntity> publicShelves = magicShelfRepository.findAllByIsPublicIsTrue();
|
||||
|
||||
// Combine and deduplicate (user's shelves that are also public shouldn't appear twice)
|
||||
Set<Long> seenIds = new HashSet<>();
|
||||
List<MagicShelfEntity> 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<MobileMagicShelfSummary> summaries = allShelves.stream()
|
||||
.map(mobileBookMapper::toMagicShelfSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> categories;
|
||||
@@ -48,10 +42,10 @@ public class MobileBookDetail {
|
||||
private List<String> fileTypes;
|
||||
private List<MobileBookFile> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<T> {
|
||||
private List<T> content;
|
||||
private int page;
|
||||
private int size;
|
||||
private long totalElements;
|
||||
private int totalPages;
|
||||
private boolean hasNext;
|
||||
private boolean hasPrevious;
|
||||
|
||||
public static <T> MobilePageResponse<T> of(List<T> content, int page, int size, long totalElements) {
|
||||
int totalPages = size > 0 ? (int) Math.ceil((double) totalElements / size) : 0;
|
||||
return MobilePageResponse.<T>builder()
|
||||
.content(content)
|
||||
.page(page)
|
||||
.size(size)
|
||||
.totalElements(totalElements)
|
||||
.totalPages(totalPages)
|
||||
.hasNext(page < totalPages - 1)
|
||||
.hasPrevious(page > 0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> mapAuthors(Set<AuthorEntity> 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) {
|
||||
|
||||
@@ -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<MobileBookSummary> 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<Long> 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<BookEntity> spec = buildSpecification(
|
||||
accessibleLibraryIds, libraryId, shelfId, status, search, userId);
|
||||
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
Set<Long> bookIds = bookPage.getContent().stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
|
||||
List<MobileBookSummary> 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<Long> 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<MobileBookSummary> 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<Long> 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<BookEntity> spec = MobileBookSpecification.combine(
|
||||
MobileBookSpecification.notDeleted(),
|
||||
MobileBookSpecification.hasDigitalFile(),
|
||||
MobileBookSpecification.inLibraries(accessibleLibraryIds),
|
||||
MobileBookSpecification.searchText(query)
|
||||
);
|
||||
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
Set<Long> bookIds = bookPage.getContent().stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
|
||||
List<MobileBookSummary> 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<MobileBookSummary> getContinueReading(Integer limit) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int maxItems = limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : 10;
|
||||
|
||||
Specification<BookEntity> spec = MobileBookSpecification.combine(
|
||||
MobileBookSpecification.notDeleted(),
|
||||
MobileBookSpecification.hasDigitalFile(),
|
||||
MobileBookSpecification.inLibraries(accessibleLibraryIds),
|
||||
MobileBookSpecification.inProgress(userId)
|
||||
);
|
||||
|
||||
List<BookEntity> books = bookRepository.findAll(spec);
|
||||
|
||||
Set<Long> bookIds = books.stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> 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<MobileBookSummary> getRecentlyAdded(Integer limit) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int maxItems = limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : 10;
|
||||
|
||||
Specification<BookEntity> 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<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
Set<Long> bookIds = bookPage.getContent().stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> 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<MobileBookSummary> 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<Long> bookIds = booksPage.getContent().stream()
|
||||
.map(book -> book.getId())
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
|
||||
List<MobileBookSummary> 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<Long> 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<Long> 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<Long> 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<Long, UserBookProgressEntity> getProgressMap(Long userId, Set<Long> bookIds) {
|
||||
if (bookIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return userBookProgressRepository.findByUserIdAndBookIdIn(userId, bookIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
p -> p.getBook().getId(),
|
||||
Function.identity()
|
||||
));
|
||||
}
|
||||
|
||||
private Specification<BookEntity> buildSpecification(
|
||||
Set<Long> accessibleLibraryIds,
|
||||
Long libraryId,
|
||||
Long shelfId,
|
||||
ReadStatus status,
|
||||
String search,
|
||||
Long userId) {
|
||||
|
||||
List<Specification<BookEntity>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<BookEntity> inLibraries(Collection<Long> libraryIds) {
|
||||
return (root, query, cb) -> {
|
||||
if (libraryIds == null || libraryIds.isEmpty()) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
return root.get("library").get("id").in(libraryIds);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> 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<BookEntity> inShelf(Long shelfId) {
|
||||
return (root, query, cb) -> {
|
||||
if (shelfId == null) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Join<BookEntity, ShelfEntity> shelvesJoin = root.join("shelves", JoinType.INNER);
|
||||
return cb.equal(shelvesJoin.get("id"), shelfId);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> withReadStatus(ReadStatus status, Long userId) {
|
||||
return (root, query, cb) -> {
|
||||
if (status == null || userId == null) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<UserBookProgressEntity> 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<BookEntity> inProgress(Long userId) {
|
||||
return (root, query, cb) -> {
|
||||
if (userId == null) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<UserBookProgressEntity> 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<BookEntity> 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<BookEntity> searchText(String searchQuery) {
|
||||
return (root, query, cb) -> {
|
||||
if (searchQuery == null || searchQuery.trim().isEmpty()) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
String pattern = "%" + searchQuery.toLowerCase().trim() + "%";
|
||||
|
||||
Join<BookEntity, BookMetadataEntity> metadataJoin = root.join("metadata", JoinType.LEFT);
|
||||
Join<BookMetadataEntity, AuthorEntity> authorsJoin = metadataJoin.join("authors", JoinType.LEFT);
|
||||
|
||||
List<Predicate> 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<BookEntity> notDeleted() {
|
||||
return (root, query, cb) -> cb.or(
|
||||
cb.isNull(root.get("deleted")),
|
||||
cb.equal(root.get("deleted"), false)
|
||||
);
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> hasDigitalFile() {
|
||||
return (root, query, cb) -> cb.or(
|
||||
cb.isNull(root.get("isPhysical")),
|
||||
cb.equal(root.get("isPhysical"), false)
|
||||
);
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static Specification<BookEntity> combine(Specification<BookEntity>... specs) {
|
||||
Specification<BookEntity> result = (root, query, cb) -> cb.conjunction();
|
||||
for (Specification<BookEntity> spec : specs) {
|
||||
if (spec != null) {
|
||||
result = result.and(spec);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
|
||||
public interface BookCompletionHeatmapDto {
|
||||
Integer getYear();
|
||||
Integer getMonth();
|
||||
Long getCount();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* Example:
|
||||
* <pre>
|
||||
* Library/
|
||||
* └── American Gods/
|
||||
* ├── American Gods.epub
|
||||
* ├── American Gods.m4b
|
||||
* └── American Gods - 10th Anniversary.pdf
|
||||
* </pre>
|
||||
* 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.
|
||||
* <p>
|
||||
* Use this when your library has mixed organization or you're unsure.
|
||||
*/
|
||||
AUTO_DETECT
|
||||
}
|
||||
|
||||
@@ -36,6 +36,19 @@ public interface UserBookFileProgressRepository extends JpaRepository<UserBookFi
|
||||
@Param("bookId") Long bookId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
SELECT ubfp FROM UserBookFileProgressEntity ubfp
|
||||
WHERE ubfp.user.id = :userId
|
||||
AND ubfp.bookFile.book.id = :bookId
|
||||
AND ubfp.bookFile.bookType = com.adityachandel.booklore.model.enums.BookFileType.AUDIOBOOK
|
||||
ORDER BY ubfp.lastReadTime DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
Optional<UserBookFileProgressEntity> findMostRecentAudiobookProgressByUserIdAndBookId(
|
||||
@Param("userId") Long userId,
|
||||
@Param("bookId") Long bookId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
SELECT ubfp FROM UserBookFileProgressEntity ubfp
|
||||
WHERE ubfp.user.id = :userId
|
||||
|
||||
@@ -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<UserBookProgre
|
||||
AND ubp.book.id IN :bookIds
|
||||
""")
|
||||
int bulkUpdatePersonalRating(@Param("userId") Long userId, @Param("bookIds") List<Long> 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<BookCompletionHeatmapDto> findBookCompletionHeatmap(
|
||||
@Param("userId") Long userId,
|
||||
@Param("startYear") int startYear,
|
||||
@Param("endYear") int endYear);
|
||||
}
|
||||
|
||||
@@ -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<BookEntity, List<LibraryFile>> filesToAttach,
|
||||
Map<String, List<LibraryFile>> 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<String, List<LibraryFile>> groupForInitialScan(List<LibraryFile> 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<LibraryFile> newFiles, LibraryEntity libraryEntity) {
|
||||
LibraryOrganizationMode mode = getOrganizationMode(libraryEntity);
|
||||
|
||||
@@ -76,7 +51,6 @@ public class BookGroupingService {
|
||||
}
|
||||
}
|
||||
|
||||
// Group unmatched files into new book groups
|
||||
Map<String, List<LibraryFile>> 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<String, List<LibraryFile>> groupByFolder(List<LibraryFile> files) {
|
||||
Map<String, List<LibraryFile>> 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<BookEntity> booksInDirectory = bookRepository.findAllByLibraryPathIdAndFileSubPath(libraryPathId, fileSubPath);
|
||||
|
||||
// Filter to non-deleted books only
|
||||
List<BookEntity> 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<BookEntity> 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<BookEntity> booksInDirectory) {
|
||||
// Filter to books with files for filename-based matching
|
||||
List<BookEntity> 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<BookEntity> booksInDirectory) {
|
||||
// Filter to books with files for filename-based matching
|
||||
List<BookEntity> 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<BookEntity> books) {
|
||||
String fileKey = BookFileGroupingUtils.extractGroupingKey(file.getFileName());
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BookEntity> 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<BookEntity> 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<BookEntity> 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;
|
||||
|
||||
Reference in New Issue
Block a user