feat(reader): add PDF annotations, Range streaming, and optimized chunk loading (#2701)

This commit is contained in:
ACX
2026-02-11 16:51:59 -07:00
committed by GitHub
parent 16de2d006f
commit 9ed6a3fe72
31 changed files with 450 additions and 749 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"));
configuration.setExposedHeaders(List.of("Content-Disposition"));
configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type", "Range"));
configuration.setExposedHeaders(List.of("Content-Disposition", "Accept-Ranges", "Content-Range", "Content-Length"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

View File

@@ -18,7 +18,7 @@ public class ImageCachingFilter extends OncePerRequestFilter {
String uri = request.getRequestURI();
if (uri.startsWith("/api/v1/media/book/") &&
(uri.contains("/cover") || uri.contains("/thumbnail") || uri.contains("/backup-cover") ||
uri.contains("/pdf/pages/") || uri.contains("/cbx/pages/"))) {
uri.contains("/cbx/pages/"))) {
response.setHeader(HttpHeaders.CACHE_CONTROL, "public, max-age=3600");
response.setHeader(HttpHeaders.EXPIRES, String.valueOf(System.currentTimeMillis() + 3600_000));
}

View File

@@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
@@ -119,14 +120,19 @@ public class BookController {
return ResponseEntity.ok(bookMetadataService.getComicInfoMetadata(bookId));
}
@Operation(summary = "Get book content", description = "Retrieve the binary content of a book for reading.")
@ApiResponse(responseCode = "200", description = "Book content returned successfully")
@Operation(summary = "Get book content", description = "Retrieve the binary content of a book for reading. Supports HTTP Range requests for partial content streaming.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Full book content returned"),
@ApiResponse(responseCode = "206", description = "Partial content returned for Range request")
})
@GetMapping("/{bookId}/content")
@CheckBookAccess(bookIdParam = "bookId")
public ResponseEntity<Resource> getBookContent(
public void getBookContent(
@Parameter(description = "ID of the book") @PathVariable long bookId,
@Parameter(description = "Optional book type for alternative format (e.g., EPUB, PDF, MOBI)") @RequestParam(required = false) String bookType) {
return bookService.getBookContent(bookId, bookType);
@Parameter(description = "Optional book type for alternative format (e.g., EPUB, PDF, MOBI)") @RequestParam(required = false) String bookType,
HttpServletRequest request,
HttpServletResponse response) throws java.io.IOException {
bookService.streamBookContent(bookId, bookType, request, response);
}
@Operation(summary = "Download book", description = "Download the book file. Requires download permission or admin.")

View File

@@ -3,7 +3,6 @@ package org.booklore.controller;
import org.booklore.service.book.BookService;
import org.booklore.service.bookdrop.BookDropService;
import org.booklore.service.reader.CbxReaderService;
import org.booklore.service.reader.PdfReaderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -25,7 +24,6 @@ import java.io.IOException;
public class BookMediaController {
private final BookService bookService;
private final PdfReaderService pdfReaderService;
private final CbxReaderService cbxReaderService;
private final BookDropService bookDropService;
@@ -57,18 +55,6 @@ public class BookMediaController {
return ResponseEntity.ok(bookService.getAudiobookCover(bookId));
}
@Operation(summary = "Get PDF page as image", description = "Retrieve a specific page from a PDF book as an image.")
@ApiResponse(responseCode = "200", description = "PDF page image returned successfully")
@GetMapping("/book/{bookId}/pdf/pages/{pageNumber}")
public void getPdfPage(
@Parameter(description = "ID of the book") @PathVariable Long bookId,
@Parameter(description = "Page number to retrieve") @PathVariable int pageNumber,
@Parameter(description = "Optional book type for alternative format (e.g., PDF, CBX)") @RequestParam(required = false) String bookType,
HttpServletResponse response) throws IOException {
response.setContentType(MediaType.IMAGE_JPEG_VALUE);
pdfReaderService.streamPageImage(bookId, bookType, pageNumber, response.getOutputStream());
}
@Operation(summary = "Get CBX page as image", description = "Retrieve a specific page from a CBX book as an image.")
@ApiResponse(responseCode = "200", description = "CBX page image returned successfully")
@GetMapping("/book/{bookId}/cbx/pages/{pageNumber}")

View File

@@ -0,0 +1,55 @@
package org.booklore.controller;
import org.booklore.config.security.annotation.CheckBookAccess;
import org.booklore.service.book.PdfAnnotationService;
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.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/pdf-annotations")
@Tag(name = "PDF Annotations", description = "Endpoints for managing PDF annotation data")
public class PdfAnnotationController {
private final PdfAnnotationService pdfAnnotationService;
@Operation(summary = "Get PDF annotations for a book", description = "Retrieve serialized PDF annotations for a specific book.")
@ApiResponse(responseCode = "200", description = "Annotations returned successfully")
@ApiResponse(responseCode = "204", description = "No annotations found")
@GetMapping("/book/{bookId}")
@CheckBookAccess(bookIdParam = "bookId")
public ResponseEntity<Map<String, String>> getAnnotations(
@Parameter(description = "ID of the book") @PathVariable Long bookId) {
return pdfAnnotationService.getAnnotations(bookId)
.map(data -> ResponseEntity.ok(Map.of("data", data)))
.orElse(ResponseEntity.noContent().build());
}
@Operation(summary = "Save PDF annotations for a book", description = "Save or update serialized PDF annotations for a specific book.")
@ApiResponse(responseCode = "204", description = "Annotations saved successfully")
@PutMapping("/book/{bookId}")
@CheckBookAccess(bookIdParam = "bookId")
public ResponseEntity<Void> saveAnnotations(
@Parameter(description = "ID of the book") @PathVariable Long bookId,
@RequestBody Map<String, String> body) {
pdfAnnotationService.saveAnnotations(bookId, body.get("data"));
return ResponseEntity.noContent().build();
}
@Operation(summary = "Delete PDF annotations for a book", description = "Delete all PDF annotations for a specific book.")
@ApiResponse(responseCode = "204", description = "Annotations deleted successfully")
@DeleteMapping("/book/{bookId}")
@CheckBookAccess(bookIdParam = "bookId")
public ResponseEntity<Void> deleteAnnotations(
@Parameter(description = "ID of the book") @PathVariable Long bookId) {
pdfAnnotationService.deleteAnnotations(bookId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,52 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "pdf_annotations")
public class PdfAnnotationEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private BookLoreUserEntity user;
@Column(name = "user_id", insertable = false, updatable = false)
private Long userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", nullable = false)
private BookEntity book;
@Column(name = "book_id", insertable = false, updatable = false)
private Long bookId;
@Lob
@Column(name = "data", nullable = false, columnDefinition = "LONGTEXT")
private String data;
@jakarta.persistence.Version
@Column(name = "version", nullable = false)
private Long version;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,13 @@
package org.booklore.repository;
import org.booklore.model.entity.PdfAnnotationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PdfAnnotationRepository extends JpaRepository<PdfAnnotationEntity, Long> {
Optional<PdfAnnotationEntity> findByBookIdAndUserId(Long bookId, Long userId);
void deleteByBookIdAndUserId(Long bookId, Long userId);
}

View File

@@ -18,8 +18,10 @@ import org.booklore.repository.BookFileRepository;
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import org.booklore.service.progress.ReadingProgressService;
import org.booklore.service.FileStreamingService;
import org.booklore.util.FileService;
import org.booklore.util.FileUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -63,6 +65,7 @@ public class BookService {
private final BookUpdateService bookUpdateService;
private final EbookViewerPreferenceRepository ebookViewerPreferencesRepository;
private final SidecarMetadataWriter sidecarMetadataWriter;
private final FileStreamingService fileStreamingService;
public List<Book> getBookDTOs(boolean includeDescription) {
@@ -320,6 +323,36 @@ public class BookService {
.body(resource);
}
public void streamBookContent(long bookId, String bookType, HttpServletRequest request, HttpServletResponse response) throws IOException {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
String filePath;
if (bookType != null) {
BookFileType requestedType = BookFileType.valueOf(bookType.toUpperCase());
BookFileEntity bookFile = bookEntity.getBookFiles().stream()
.filter(bf -> bf.getBookType() == requestedType)
.findFirst()
.orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("No file of type " + bookType + " found for book"));
filePath = bookFile.getFullFilePath().toString();
} else {
filePath = FileUtils.getBookFullPath(bookEntity);
}
Path path = Paths.get(filePath);
String fileName = path.getFileName().toString();
String extension = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.') + 1) : "";
String contentType = switch (extension.toLowerCase()) {
case "pdf" -> "application/pdf";
case "epub" -> "application/epub+zip";
case "mobi", "azw3" -> "application/x-mobipocket-ebook";
case "cbz" -> "application/vnd.comicbook+zip";
case "cbr" -> "application/vnd.comicbook-rar";
case "fb2" -> "application/x-fictionbook+xml";
default -> "application/octet-stream";
};
fileStreamingService.streamWithRangeSupport(path, contentType, request, response);
}
@Transactional
public ResponseEntity<BookDeletionResponse> deleteBooks(Set<Long> ids) {
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(ids);

View File

@@ -0,0 +1,76 @@
package org.booklore.service.book;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.PdfAnnotationEntity;
import org.booklore.repository.BookRepository;
import org.booklore.repository.PdfAnnotationRepository;
import org.booklore.repository.UserRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class PdfAnnotationService {
private final PdfAnnotationRepository pdfAnnotationRepository;
private final BookRepository bookRepository;
private final UserRepository userRepository;
private final AuthenticationService authenticationService;
@Transactional(readOnly = true)
public Optional<String> getAnnotations(Long bookId) {
Long userId = getCurrentUserId();
return pdfAnnotationRepository.findByBookIdAndUserId(bookId, userId)
.map(PdfAnnotationEntity::getData);
}
@Transactional
public void saveAnnotations(Long bookId, String data) {
Long userId = getCurrentUserId();
Optional<PdfAnnotationEntity> existing = pdfAnnotationRepository.findByBookIdAndUserId(bookId, userId);
if (existing.isPresent()) {
PdfAnnotationEntity entity = existing.get();
entity.setData(data);
pdfAnnotationRepository.save(entity);
log.info("Updated PDF annotations for book {} by user {}", bookId, userId);
} else {
PdfAnnotationEntity entity = PdfAnnotationEntity.builder()
.book(findBook(bookId))
.user(findUser(userId))
.data(data)
.build();
pdfAnnotationRepository.save(entity);
log.info("Created PDF annotations for book {} by user {}", bookId, userId);
}
}
@Transactional
public void deleteAnnotations(Long bookId) {
Long userId = getCurrentUserId();
pdfAnnotationRepository.deleteByBookIdAndUserId(bookId, userId);
log.info("Deleted PDF annotations for book {} by user {}", bookId, userId);
}
private Long getCurrentUserId() {
return authenticationService.getAuthenticatedUser().getId();
}
private BookEntity findBook(Long bookId) {
return bookRepository.findById(bookId)
.orElseThrow(() -> new EntityNotFoundException("Book not found: " + bookId));
}
private BookLoreUserEntity findUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
}
}

View File

@@ -1,13 +1,5 @@
package org.booklore.service.reader;
import org.booklore.exception.ApiError;
import org.booklore.model.dto.response.PdfBookInfo;
import org.booklore.model.dto.response.PdfOutlineItem;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookFileEntity;
import org.booklore.model.enums.BookFileType;
import org.booklore.repository.BookRepository;
import org.booklore.util.FileUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
@@ -18,6 +10,14 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocume
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.booklore.exception.ApiError;
import org.booklore.model.dto.response.PdfBookInfo;
import org.booklore.model.dto.response.PdfOutlineItem;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookFileEntity;
import org.booklore.model.enums.BookFileType;
import org.booklore.repository.BookRepository;
import org.booklore.util.FileUtils;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
@@ -27,7 +27,10 @@ import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -57,8 +60,8 @@ public class PdfReaderService {
}
}
public List<Integer> getAvailablePages(Long bookId) {
return getAvailablePages(bookId, null);
public void getAvailablePages(Long bookId) {
getAvailablePages(bookId, null);
}
public List<Integer> getAvailablePages(Long bookId, String bookType) {
@@ -74,10 +77,6 @@ public class PdfReaderService {
}
}
public PdfBookInfo getBookInfo(Long bookId) {
return getBookInfo(bookId, null);
}
public PdfBookInfo getBookInfo(Long bookId, String bookType) {
Path pdfPath = getBookPath(bookId, bookType);
try {

View File

@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS pdf_annotations
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
book_id BIGINT NOT NULL,
data LONGTEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (user_id, book_id)
);
CREATE INDEX IF NOT EXISTS idx_pdf_annotations_user_id ON pdf_annotations (user_id);
CREATE INDEX IF NOT EXISTS idx_pdf_annotations_book_id ON pdf_annotations (book_id);
ALTER TABLE pdf_annotations
ADD CONSTRAINT fk_pdf_annotations_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE pdf_annotations
ADD CONSTRAINT fk_pdf_annotations_book
FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE;

View File

@@ -10,6 +10,7 @@ import org.booklore.service.book.BookUpdateService;
import org.booklore.service.progress.ReadingProgressService;
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import org.booklore.service.FileStreamingService;
import org.booklore.util.FileService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -53,6 +54,7 @@ class BookServiceDeleteTests {
MonitoringRegistrationService monitoringRegistrationService = Mockito.mock(MonitoringRegistrationService.class);
BookUpdateService bookUpdateService = Mockito.mock(BookUpdateService.class);
SidecarMetadataWriter sidecarMetadataWriter = Mockito.mock(SidecarMetadataWriter.class);
FileStreamingService fileStreamingService = Mockito.mock(FileStreamingService.class);
bookService = new BookService(
bookRepository,
@@ -70,7 +72,8 @@ class BookServiceDeleteTests {
monitoringRegistrationService,
bookUpdateService,
ebookViewerPreferenceRepository,
sidecarMetadataWriter
sidecarMetadataWriter,
fileStreamingService
);
}