Backend APIs

This commit is contained in:
acx10
2026-01-29 17:37:11 -07:00
parent 2b6cc4be7c
commit 17aa633b41
25 changed files with 1054 additions and 149 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.adityachandel.booklore.model.dto;
public interface BookCompletionHeatmapDto {
Integer getYear();
Integer getMonth();
Long getCount();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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