mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
perf: optimize book list API with ETag, delta sync, and IndexedDB caching (#2753)
This commit is contained in:
@@ -240,8 +240,8 @@ public class SecurityConfig {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||
configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type", "Range"));
|
||||
configuration.setExposedHeaders(List.of("Content-Disposition", "Accept-Ranges", "Content-Range", "Content-Length"));
|
||||
configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type", "Range", "If-None-Match"));
|
||||
configuration.setExposedHeaders(List.of("Content-Disposition", "Accept-Ranges", "Content-Range", "Content-Length", "ETag", "Date"));
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.booklore.model.dto.request.ReadStatusUpdateRequest;
|
||||
import org.booklore.model.dto.request.ShelvesAssignmentRequest;
|
||||
import org.booklore.model.dto.response.BookDeletionResponse;
|
||||
import org.booklore.model.dto.response.BookStatusUpdateResponse;
|
||||
import org.booklore.model.dto.response.BookSyncResponse;
|
||||
import org.booklore.model.dto.response.PersonalRatingUpdateResponse;
|
||||
import org.booklore.model.enums.ResetProgressType;
|
||||
import org.booklore.service.book.BookFileAttachmentService;
|
||||
@@ -34,10 +35,13 @@ import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -55,13 +59,34 @@ public class BookController {
|
||||
private final ReadingProgressService readingProgressService;
|
||||
private final PhysicalBookService physicalBookService;
|
||||
|
||||
@Operation(summary = "Get all books", description = "Retrieve a list of all books. Optionally include descriptions.")
|
||||
@ApiResponse(responseCode = "200", description = "List of books returned successfully")
|
||||
@Operation(summary = "Get all books", description = "Retrieve a list of all books. Optionally include descriptions. Supports ETag-based conditional requests.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "List of books returned successfully"),
|
||||
@ApiResponse(responseCode = "304", description = "Not modified — client cache is still valid")
|
||||
})
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Book>> getBooks(
|
||||
@Parameter(description = "Include book descriptions in the response")
|
||||
@RequestParam(required = false, defaultValue = "false") boolean withDescription,
|
||||
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
|
||||
String etag = bookService.computeBooksETag(withDescription);
|
||||
if (etag.equals(ifNoneMatch)) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).eTag(etag).build();
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.eTag(etag)
|
||||
.cacheControl(CacheControl.noCache().cachePrivate())
|
||||
.body(bookService.getBookDTOs(withDescription));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get book changes since timestamp", description = "Returns books added/modified since the given timestamp and IDs of deleted books. Used for delta synchronization.")
|
||||
@ApiResponse(responseCode = "200", description = "Delta sync response returned successfully")
|
||||
@GetMapping("/delta")
|
||||
public ResponseEntity<BookSyncResponse> getBooksDelta(
|
||||
@Parameter(description = "Timestamp to fetch changes since (ISO-8601 instant)") @RequestParam Instant since,
|
||||
@Parameter(description = "Include book descriptions in the response")
|
||||
@RequestParam(required = false, defaultValue = "false") boolean withDescription) {
|
||||
return ResponseEntity.ok(bookService.getBookDTOs(withDescription));
|
||||
return ResponseEntity.ok(bookService.getBooksDelta(since, withDescription));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get a book by ID", description = "Retrieve details of a specific book by its ID.")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.booklore.model.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import org.booklore.model.dto.Book;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class BookSyncResponse {
|
||||
private List<Book> books;
|
||||
private List<Long> deletedIds;
|
||||
private String syncTimestamp;
|
||||
private long totalBookCount;
|
||||
}
|
||||
@@ -118,6 +118,29 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT COUNT(b) FROM BookEntity b WHERE b.deleted = TRUE")
|
||||
long countAllSoftDeleted();
|
||||
|
||||
@Query("SELECT COUNT(b), MAX(COALESCE(b.metadataUpdatedAt, b.addedOn)), MAX(b.addedOn) " +
|
||||
"FROM BookEntity b WHERE b.deleted IS NULL OR b.deleted = false")
|
||||
Object[] getBookStats();
|
||||
|
||||
@Query("SELECT COUNT(b), MAX(COALESCE(b.metadataUpdatedAt, b.addedOn)), MAX(b.addedOn) " +
|
||||
"FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
Object[] getBookStatsByLibraryIds(@Param("libraryIds") Set<Long> libraryIds);
|
||||
|
||||
@Query("SELECT b.id FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false) " +
|
||||
"AND (b.addedOn > :since OR b.metadataUpdatedAt > :since)")
|
||||
Set<Long> findBookIdsModifiedSince(@Param("since") Instant since);
|
||||
|
||||
@Query("SELECT b.id FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false) " +
|
||||
"AND (b.addedOn > :since OR b.metadataUpdatedAt > :since) AND b.library.id IN :libraryIds")
|
||||
Set<Long> findBookIdsModifiedSinceByLibraryIds(@Param("since") Instant since, @Param("libraryIds") Set<Long> libraryIds);
|
||||
|
||||
@Query("SELECT b.id FROM BookEntity b WHERE b.deleted = true AND b.deletedAt > :since")
|
||||
List<Long> findDeletedBookIdsSince(@Param("since") Instant since);
|
||||
|
||||
@Query("SELECT b.id FROM BookEntity b WHERE b.deleted = true AND b.deletedAt > :since " +
|
||||
"AND b.library.id IN :libraryIds")
|
||||
List<Long> findDeletedBookIdsSinceByLibraryIds(@Param("since") Instant since, @Param("libraryIds") Set<Long> libraryIds);
|
||||
|
||||
@Query(value = """
|
||||
SELECT b.*
|
||||
FROM book b
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -20,6 +21,14 @@ public interface UserBookProgressRepository extends JpaRepository<UserBookProgre
|
||||
|
||||
List<UserBookProgressEntity> findByUserIdAndBookIdIn(Long userId, Set<Long> bookIds);
|
||||
|
||||
@Query("SELECT MAX(COALESCE(ubp.lastReadTime, ubp.readStatusModifiedTime)) " +
|
||||
"FROM UserBookProgressEntity ubp WHERE ubp.user.id = :userId")
|
||||
Instant getMaxProgressTimestamp(@Param("userId") Long userId);
|
||||
|
||||
@Query("SELECT ubp.book.id FROM UserBookProgressEntity ubp WHERE ubp.user.id = :userId " +
|
||||
"AND (ubp.lastReadTime > :since OR ubp.readStatusModifiedTime > :since)")
|
||||
Set<Long> findBookIdsWithProgressChangedSince(@Param("userId") Long userId, @Param("since") Instant since);
|
||||
|
||||
@Query("""
|
||||
SELECT ubp FROM UserBookProgressEntity ubp
|
||||
WHERE ubp.user.id = :userId
|
||||
|
||||
@@ -37,6 +37,10 @@ public class BookQueryService {
|
||||
return bookRepository.findAllWithMetadataByIds(bookIds);
|
||||
}
|
||||
|
||||
public List<Book> mapEntitiesToDto(List<BookEntity> entities, boolean includeDescription, Long userId) {
|
||||
return mapBooksToDto(entities, includeDescription, userId, !includeDescription);
|
||||
}
|
||||
|
||||
public List<BookEntity> getAllFullBookEntities() {
|
||||
return bookRepository.findAllFullBooks();
|
||||
}
|
||||
@@ -156,6 +160,12 @@ public class BookQueryService {
|
||||
m.setAudibleReviewCount(null);
|
||||
m.setLubimyczytacRating(null);
|
||||
|
||||
// Strip empty metadata collections
|
||||
if (m.getMoods() != null && m.getMoods().isEmpty()) m.setMoods(null);
|
||||
if (m.getTags() != null && m.getTags().isEmpty()) m.setTags(null);
|
||||
if (m.getAuthors() != null && m.getAuthors().isEmpty()) m.setAuthors(null);
|
||||
if (m.getCategories() != null && m.getCategories().isEmpty()) m.setCategories(null);
|
||||
|
||||
// Strip ComicMetadata fields
|
||||
ComicMetadata cm = m.getComicMetadata();
|
||||
if (cm != null) {
|
||||
@@ -202,6 +212,10 @@ public class BookQueryService {
|
||||
cm.setNotes(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip empty book-level collections
|
||||
if (dto.getAlternativeFormats() != null && dto.getAlternativeFormats().isEmpty()) dto.setAlternativeFormats(null);
|
||||
if (dto.getSupplementaryFiles() != null && dto.getSupplementaryFiles().isEmpty()) dto.setSupplementaryFiles(null);
|
||||
}
|
||||
|
||||
private boolean computeAllMetadataLocked(BookMetadata m) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.booklore.model.dto.*;
|
||||
import org.booklore.model.dto.request.ReadProgressRequest;
|
||||
import org.booklore.model.dto.response.BookDeletionResponse;
|
||||
import org.booklore.model.dto.response.BookStatusUpdateResponse;
|
||||
import org.booklore.model.dto.response.BookSyncResponse;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookFileEntity;
|
||||
import org.booklore.model.entity.LibraryPathEntity;
|
||||
@@ -41,6 +42,8 @@ import java.net.MalformedURLException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -75,9 +78,7 @@ public class BookService {
|
||||
List<Book> books = isAdmin
|
||||
? bookQueryService.getAllBooks(includeDescription)
|
||||
: bookQueryService.getAllBooksByLibraryIds(
|
||||
user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(Collectors.toSet()),
|
||||
getUserLibraryIds(user),
|
||||
includeDescription,
|
||||
user.getId()
|
||||
);
|
||||
@@ -94,12 +95,123 @@ public class BookService {
|
||||
progressMap.get(book.getId()),
|
||||
fileProgressMap.get(book.getId())
|
||||
);
|
||||
book.setShelves(filterShelvesByUserId(book.getShelves(), user.getId()));
|
||||
Set<Shelf> filtered = filterShelvesByUserId(book.getShelves(), user.getId());
|
||||
book.setShelves(!includeDescription && filtered != null && filtered.isEmpty() ? null : filtered);
|
||||
});
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
public String computeBooksETag(boolean includeDescription) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
boolean isAdmin = user.getPermissions().isAdmin();
|
||||
|
||||
Object[] row = extractStatsRow(isAdmin
|
||||
? bookRepository.getBookStats()
|
||||
: bookRepository.getBookStatsByLibraryIds(getUserLibraryIds(user)));
|
||||
|
||||
long count = ((Number) row[0]).longValue();
|
||||
long maxBookTs = toEpochMilli(row[1]);
|
||||
long maxAddedTs = toEpochMilli(row[2]);
|
||||
long maxProgressTs = toEpochMilli(userBookProgressRepository.getMaxProgressTimestamp(user.getId()));
|
||||
|
||||
return "\"" + count + "-" + maxBookTs + "-" + maxAddedTs + "-" + maxProgressTs
|
||||
+ "-" + includeDescription + "\"";
|
||||
}
|
||||
|
||||
public BookSyncResponse getBooksDelta(Instant since, boolean includeDescription) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
boolean isAdmin = user.getPermissions().isAdmin();
|
||||
Set<Long> libraryIds = isAdmin ? null : getUserLibraryIds(user);
|
||||
|
||||
// Find book IDs modified since the given timestamp
|
||||
Set<Long> modifiedBookIds = isAdmin
|
||||
? bookRepository.findBookIdsModifiedSince(since)
|
||||
: bookRepository.findBookIdsModifiedSinceByLibraryIds(since, libraryIds);
|
||||
|
||||
// Find book IDs with progress changes
|
||||
Set<Long> progressChangedIds = userBookProgressRepository.findBookIdsWithProgressChangedSince(user.getId(), since);
|
||||
|
||||
// Combine all changed IDs
|
||||
Set<Long> allChangedIds = new HashSet<>(modifiedBookIds);
|
||||
allChangedIds.addAll(progressChangedIds);
|
||||
|
||||
// Find deleted book IDs
|
||||
List<Long> deletedIds = isAdmin
|
||||
? bookRepository.findDeletedBookIdsSince(since)
|
||||
: bookRepository.findDeletedBookIdsSinceByLibraryIds(since, libraryIds);
|
||||
|
||||
// Fetch and enrich changed books
|
||||
List<Book> changedBooks = List.of();
|
||||
if (!allChangedIds.isEmpty()) {
|
||||
List<BookEntity> bookEntities = bookQueryService.findAllWithMetadataByIds(allChangedIds);
|
||||
if (!isAdmin) {
|
||||
// Content restriction is handled by BookQueryService for non-admin users,
|
||||
// but since we're using findAllWithMetadataByIds directly, filter by library
|
||||
bookEntities = bookEntities.stream()
|
||||
.filter(b -> libraryIds.contains(b.getLibrary().getId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
changedBooks = enrichBookEntities(bookEntities, user, includeDescription);
|
||||
}
|
||||
|
||||
// Get total book count for client to detect full-sync needs
|
||||
Object[] row = extractStatsRow(isAdmin
|
||||
? bookRepository.getBookStats()
|
||||
: bookRepository.getBookStatsByLibraryIds(libraryIds));
|
||||
long totalCount = ((Number) row[0]).longValue();
|
||||
|
||||
return BookSyncResponse.builder()
|
||||
.books(changedBooks)
|
||||
.deletedIds(deletedIds)
|
||||
.syncTimestamp(Instant.now().toString())
|
||||
.totalBookCount(totalCount)
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<Book> enrichBookEntities(List<BookEntity> bookEntities, BookLoreUser user, boolean includeDescription) {
|
||||
// Map entities through the same DTO pipeline as full fetch (includes field stripping)
|
||||
List<Book> books = bookQueryService.mapEntitiesToDto(bookEntities, includeDescription, user.getId());
|
||||
|
||||
Set<Long> bookIds = books.stream().map(Book::getId).collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap =
|
||||
readingProgressService.fetchUserProgress(user.getId(), bookIds);
|
||||
Map<Long, UserBookFileProgressEntity> fileProgressMap =
|
||||
readingProgressService.fetchUserFileProgress(user.getId(), bookIds);
|
||||
|
||||
books.forEach(book -> {
|
||||
readingProgressService.enrichBookWithProgress(
|
||||
book,
|
||||
progressMap.get(book.getId()),
|
||||
fileProgressMap.get(book.getId())
|
||||
);
|
||||
Set<Shelf> filtered = filterShelvesByUserId(book.getShelves(), user.getId());
|
||||
book.setShelves(!includeDescription && filtered != null && filtered.isEmpty() ? null : filtered);
|
||||
});
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
private long toEpochMilli(Object value) {
|
||||
if (value == null) return 0;
|
||||
if (value instanceof Instant inst) return inst.toEpochMilli();
|
||||
if (value instanceof Timestamp ts) return ts.toInstant().toEpochMilli();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private Object[] extractStatsRow(Object[] result) {
|
||||
if (result.length > 0 && result[0] instanceof Object[]) {
|
||||
return (Object[]) result[0];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Set<Long> getUserLibraryIds(BookLoreUser user) {
|
||||
return user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public List<Book> getBooksByIds(Set<Long> bookIds, boolean withDescription) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user