perf: optimize book list API with ETag, delta sync, and IndexedDB caching (#2753)

This commit is contained in:
ACX
2026-02-14 17:47:08 -07:00
committed by GitHub
parent 44002c2c3c
commit 6f77223686
13 changed files with 1143 additions and 1220 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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