mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-03-16 16:42:08 -05:00
feat: OPDS v2 support for libraries, shelves (#1129)
* opds v2 support for libraries, shelves * opds v2 support for recently added filter
This commit is contained in:
+62
-6
@@ -21,27 +21,63 @@ public class OpdsController {
|
||||
private final OpdsService opdsService;
|
||||
private final BookService bookService;
|
||||
|
||||
@GetMapping(value = "/catalog", produces = "application/atom+xml;profile=opds-catalog")
|
||||
@GetMapping(produces = {"application/opds+json"})
|
||||
public ResponseEntity<String> getRootNavigation(HttpServletRequest request) {
|
||||
// Only OPDS 2 navigation is defined for root
|
||||
String nav = opdsService.generateOpdsV2Navigation(request);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/opds+json;profile=navigation"))
|
||||
.body(nav);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/libraries", produces = {"application/opds+json"})
|
||||
public ResponseEntity<String> getLibrariesNavigation(HttpServletRequest request) {
|
||||
String nav = opdsService.generateOpdsV2LibrariesNavigation(request);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/opds+json;profile=navigation"))
|
||||
.body(nav);
|
||||
}
|
||||
|
||||
|
||||
@GetMapping(value = "/shelves", produces = {"application/opds+json"})
|
||||
public ResponseEntity<String> getShelvesNavigation(HttpServletRequest request) {
|
||||
String nav = opdsService.generateOpdsV2ShelvesNavigation(request);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/opds+json;profile=navigation"))
|
||||
.body(nav);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/catalog", produces = {"application/opds+json", "application/atom+xml;profile=opds-catalog"})
|
||||
public ResponseEntity<String> getCatalogFeed(HttpServletRequest request) {
|
||||
String feed = opdsService.generateCatalogFeed(request);
|
||||
MediaType contentType = selectContentType(request);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog"))
|
||||
.contentType(contentType)
|
||||
.body(feed);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/search", produces = "application/atom+xml;profile=opds-catalog")
|
||||
@GetMapping(value = "/search", produces = {"application/opds+json", "application/atom+xml;profile=opds-catalog"})
|
||||
public ResponseEntity<String> search(HttpServletRequest request) {
|
||||
String feed = opdsService.generateSearchResults(request, request.getParameter("q"));
|
||||
MediaType contentType = selectContentType(request);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog"))
|
||||
.contentType(contentType)
|
||||
.body(feed);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/search.opds", produces = "application/atom+xml;profile=opds-catalog")
|
||||
@GetMapping(value = "/recent", produces = {"application/opds+json"})
|
||||
public ResponseEntity<String> recent(HttpServletRequest request) {
|
||||
String feed = opdsService.generateRecentFeed(request);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/opds+json;profile=acquisition"))
|
||||
.body(feed);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/search.opds", produces = "application/opensearchdescription+xml")
|
||||
public ResponseEntity<String> searchDescription(HttpServletRequest request) {
|
||||
String feed = opdsService.generateSearchDescription(request);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog"))
|
||||
.contentType(MediaType.parseMediaType("application/opensearchdescription+xml"))
|
||||
.body(feed);
|
||||
}
|
||||
|
||||
@@ -59,4 +95,24 @@ public class OpdsController {
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + coverImage.getFilename() + "\"")
|
||||
.body(coverImage);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/publications/{bookId}", produces = "application/opds-publication+json")
|
||||
public ResponseEntity<String> getPublication(HttpServletRequest request, @PathVariable long bookId) {
|
||||
String publication = opdsService.generateOpdsV2Publication(request, bookId);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/opds-publication+json"))
|
||||
.body(publication);
|
||||
}
|
||||
|
||||
private MediaType selectContentType(HttpServletRequest request) {
|
||||
// Force OPDS 2 JSON when using v2-only filters
|
||||
if (request.getParameter("shelfId") != null || request.getParameter("libraryId") != null) {
|
||||
return MediaType.parseMediaType("application/opds+json;profile=acquisition");
|
||||
}
|
||||
String accept = request.getHeader("Accept");
|
||||
if (accept != null && (accept.contains("application/opds+json") || accept.contains("version=2.0"))) {
|
||||
return MediaType.parseMediaType("application/opds+json;profile=acquisition");
|
||||
}
|
||||
return MediaType.parseMediaType("application/atom+xml;profile=opds-catalog");
|
||||
}
|
||||
}
|
||||
|
||||
+68
-1
@@ -2,6 +2,8 @@ package com.adityachandel.booklore.repository;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.*;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -37,6 +39,10 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadata();
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
|
||||
@Query(value = "SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
|
||||
Page<BookEntity> findAllWithMetadata(Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByIds(@Param("bookIds") Set<Long> bookIds);
|
||||
@@ -49,10 +55,19 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
|
||||
@Query(value = "SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
Page<BookEntity> findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds, Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
|
||||
@Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
|
||||
@Query(value = "SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)",
|
||||
countQuery = "SELECT COUNT(DISTINCT b.id) FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
Page<BookEntity> findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId, Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.fileSizeKb IS NULL AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByFileSizeKbIsNull();
|
||||
@@ -82,6 +97,30 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
""")
|
||||
List<BookEntity> searchByMetadata(@Param("text") String text);
|
||||
|
||||
@Query(value = """
|
||||
SELECT DISTINCT b FROM BookEntity b
|
||||
LEFT JOIN b.metadata m
|
||||
LEFT JOIN m.authors a
|
||||
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
|
||||
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
)
|
||||
""",
|
||||
countQuery = """
|
||||
SELECT COUNT(DISTINCT b.id) FROM BookEntity b
|
||||
LEFT JOIN b.metadata m
|
||||
LEFT JOIN m.authors a
|
||||
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
|
||||
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
)
|
||||
""")
|
||||
Page<BookEntity> searchByMetadata(@Param("text") String text, Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
SELECT DISTINCT b FROM BookEntity b
|
||||
LEFT JOIN FETCH b.metadata m
|
||||
@@ -98,8 +137,36 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
""")
|
||||
List<BookEntity> searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection<Long> libraryIds);
|
||||
|
||||
@Query(value = """
|
||||
SELECT DISTINCT b FROM BookEntity b
|
||||
LEFT JOIN b.metadata m
|
||||
LEFT JOIN m.authors a
|
||||
WHERE (b.deleted IS NULL OR b.deleted = false)
|
||||
AND b.library.id IN :libraryIds
|
||||
AND (
|
||||
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
)
|
||||
""",
|
||||
countQuery = """
|
||||
SELECT COUNT(DISTINCT b.id) FROM BookEntity b
|
||||
LEFT JOIN b.metadata m
|
||||
LEFT JOIN m.authors a
|
||||
WHERE (b.deleted IS NULL OR b.deleted = false)
|
||||
AND b.library.id IN :libraryIds
|
||||
AND (
|
||||
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
)
|
||||
""")
|
||||
Page<BookEntity> searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection<Long> libraryIds, Pageable pageable);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("DELETE FROM BookEntity b WHERE b.deletedAt IS NOT NULL AND b.deletedAt < :cutoff")
|
||||
int deleteAllByDeletedAtBefore(Instant cutoff);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import com.adityachandel.booklore.repository.BookRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -31,6 +37,36 @@ public class BookQueryService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Page<Book> getAllBooksPage(boolean includeDescription, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size);
|
||||
Page<BookEntity> books = bookRepository.findAllWithMetadata(pageable);
|
||||
List<Book> mapped = books.getContent().stream()
|
||||
.map(book -> {
|
||||
Book dto = bookMapperV2.toDTO(book);
|
||||
if (!includeDescription && dto.getMetadata() != null) {
|
||||
dto.getMetadata().setDescription(null);
|
||||
}
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
return new PageImpl<>(mapped, pageable, books.getTotalElements());
|
||||
}
|
||||
|
||||
public Page<Book> getRecentBooksPage(boolean includeDescription, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by("addedOn").descending());
|
||||
Page<BookEntity> books = bookRepository.findAllWithMetadata(pageable);
|
||||
List<Book> mapped = books.getContent().stream()
|
||||
.map(book -> {
|
||||
Book dto = bookMapperV2.toDTO(book);
|
||||
if (!includeDescription && dto.getMetadata() != null) {
|
||||
dto.getMetadata().setDescription(null);
|
||||
}
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
return new PageImpl<>(mapped, pageable, books.getTotalElements());
|
||||
}
|
||||
|
||||
public List<Book> getAllBooksByLibraryIds(Set<Long> libraryIds, boolean includeDescription) {
|
||||
List<BookEntity> books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds);
|
||||
return books.stream()
|
||||
@@ -44,6 +80,36 @@ public class BookQueryService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Page<Book> getAllBooksByLibraryIdsPage(Set<Long> libraryIds, boolean includeDescription, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size);
|
||||
Page<BookEntity> books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds, pageable);
|
||||
List<Book> mapped = books.getContent().stream()
|
||||
.map(book -> {
|
||||
Book dto = bookMapperV2.toDTO(book);
|
||||
if (!includeDescription && dto.getMetadata() != null) {
|
||||
dto.getMetadata().setDescription(null);
|
||||
}
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
return new PageImpl<>(mapped, pageable, books.getTotalElements());
|
||||
}
|
||||
|
||||
public Page<Book> getRecentBooksByLibraryIdsPage(Set<Long> libraryIds, boolean includeDescription, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by("addedOn").descending());
|
||||
Page<BookEntity> books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds, pageable);
|
||||
List<Book> mapped = books.getContent().stream()
|
||||
.map(book -> {
|
||||
Book dto = bookMapperV2.toDTO(book);
|
||||
if (!includeDescription && dto.getMetadata() != null) {
|
||||
dto.getMetadata().setDescription(null);
|
||||
}
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
return new PageImpl<>(mapped, pageable, books.getTotalElements());
|
||||
}
|
||||
|
||||
public List<BookEntity> findAllWithMetadataByIds(Set<Long> bookIds) {
|
||||
return bookRepository.findAllWithMetadataByIds(bookIds);
|
||||
}
|
||||
@@ -59,6 +125,15 @@ public class BookQueryService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Page<Book> searchBooksByMetadataPage(String text, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size);
|
||||
Page<BookEntity> books = bookRepository.searchByMetadata(text, pageable);
|
||||
List<Book> mapped = books.getContent().stream()
|
||||
.map(bookMapperV2::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return new PageImpl<>(mapped, pageable, books.getTotalElements());
|
||||
}
|
||||
|
||||
public List<Book> searchBooksByMetadataInLibraries(String text, Set<Long> libraryIds) {
|
||||
List<BookEntity> bookEntities = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds);
|
||||
return bookEntities.stream()
|
||||
@@ -66,7 +141,33 @@ public class BookQueryService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Page<Book> searchBooksByMetadataInLibrariesPage(String text, Set<Long> libraryIds, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size);
|
||||
Page<BookEntity> books = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds, pageable);
|
||||
List<Book> mapped = books.getContent().stream()
|
||||
.map(bookMapperV2::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return new PageImpl<>(mapped, pageable, books.getTotalElements());
|
||||
}
|
||||
|
||||
public Page<Book> getAllBooksByShelfPage(Long shelfId, boolean includeDescription, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size);
|
||||
Page<BookEntity> books = bookRepository.findAllWithMetadataByShelfId(shelfId, pageable);
|
||||
List<Book> mapped = books.getContent().stream()
|
||||
.map(book -> {
|
||||
Book dto = bookMapperV2.toDTO(book);
|
||||
if (!includeDescription && dto.getMetadata() != null) {
|
||||
dto.getMetadata().setDescription(null);
|
||||
}
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
return new PageImpl<>(mapped, pageable, books.getTotalElements());
|
||||
}
|
||||
|
||||
public void saveAll(List<BookEntity> books) {
|
||||
bookRepository.saveAll(books);
|
||||
}
|
||||
|
||||
// Removed OPDS Magic Shelves support
|
||||
}
|
||||
|
||||
+519
-14
@@ -6,6 +6,8 @@ import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
|
||||
import com.adityachandel.booklore.model.dto.*;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.repository.ShelfRepository;
|
||||
import com.adityachandel.booklore.service.library.LibraryService;
|
||||
import com.adityachandel.booklore.service.BookQueryService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -15,8 +17,11 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -27,25 +32,283 @@ public class OpdsService {
|
||||
private final AuthenticationService authenticationService;
|
||||
private final UserRepository userRepository;
|
||||
private final BookLoreUserTransformer bookLoreUserTransformer;
|
||||
private final ShelfRepository shelfRepository;
|
||||
private final LibraryService libraryService;
|
||||
|
||||
|
||||
public String generateCatalogFeed(HttpServletRequest request) {
|
||||
List<Book> books = getAllowedBooks(null);
|
||||
String feedVersion = extractVersionFromAcceptHeader(request);
|
||||
Long libraryId = parseLongParam(request, "libraryId", null);
|
||||
Long shelfId = parseLongParam(request, "shelfId", null);
|
||||
String forceV2 = (libraryId != null || shelfId != null) ? "2.0" : null;
|
||||
String feedVersion = forceV2 != null ? forceV2 : extractVersionFromAcceptHeader(request);
|
||||
return switch (feedVersion) {
|
||||
case "2.0" -> generateOpdsV2Feed(books);
|
||||
default -> generateOpdsV1Feed(books, request);
|
||||
case "2.0" -> {
|
||||
int page = parseIntParam(request, "page", 1);
|
||||
int size = parseIntParam(request, "size", 50);
|
||||
var result = getAllowedBooksPage(null, libraryId, shelfId, page, size);
|
||||
var qp = new java.util.LinkedHashMap<String,String>();
|
||||
if (libraryId != null) qp.put("libraryId", String.valueOf(libraryId));
|
||||
if (shelfId != null) qp.put("shelfId", String.valueOf(shelfId));
|
||||
yield generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/catalog", qp, page, size);
|
||||
}
|
||||
default -> {
|
||||
List<Book> books = getAllowedBooks(null);
|
||||
yield generateOpdsV1Feed(books, request);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public String generateSearchResults(HttpServletRequest request, String queryParam) {
|
||||
List<Book> books = getAllowedBooks(queryParam);
|
||||
String feedVersion = extractVersionFromAcceptHeader(request);
|
||||
Long libraryId = parseLongParam(request, "libraryId", null);
|
||||
Long shelfId = parseLongParam(request, "shelfId", null);
|
||||
String forceV2 = (libraryId != null || shelfId != null) ? "2.0" : null;
|
||||
String feedVersion = forceV2 != null ? forceV2 : extractVersionFromAcceptHeader(request);
|
||||
return switch (feedVersion) {
|
||||
case "2.0" -> generateOpdsV2Feed(books);
|
||||
default -> generateOpdsV1Feed(books, request);
|
||||
case "2.0" -> {
|
||||
int page = parseIntParam(request, "page", 1);
|
||||
int size = parseIntParam(request, "size", 50);
|
||||
String q = request.getParameter("q");
|
||||
var result = getAllowedBooksPage(q, libraryId, shelfId, page, size);
|
||||
var qp = new java.util.LinkedHashMap<String,String>();
|
||||
if (q != null && !q.isBlank()) qp.put("q", q);
|
||||
if (libraryId != null) qp.put("libraryId", String.valueOf(libraryId));
|
||||
if (shelfId != null) qp.put("shelfId", String.valueOf(shelfId));
|
||||
yield generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/search", qp, page, size);
|
||||
}
|
||||
default -> {
|
||||
List<Book> books = getAllowedBooks(queryParam);
|
||||
yield generateOpdsV1Feed(books, request);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public String generateRecentFeed(HttpServletRequest request) {
|
||||
int page = parseIntParam(request, "page", 1);
|
||||
int size = parseIntParam(request, "size", 50);
|
||||
|
||||
// Determine context: legacy OPDS user vs OPDS v2 user
|
||||
OpdsUserDetails details = authenticationService.getOpdsUser();
|
||||
OpdsUser opdsUser = details.getOpdsUser();
|
||||
|
||||
var qp = new java.util.LinkedHashMap<String, String>();
|
||||
qp.put("page", String.valueOf(page));
|
||||
qp.put("size", String.valueOf(size));
|
||||
|
||||
if (opdsUser != null) {
|
||||
var result = bookQueryService.getRecentBooksPage(true, page, size);
|
||||
return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size);
|
||||
}
|
||||
|
||||
OpdsUserV2 v2 = details.getOpdsUserV2();
|
||||
BookLoreUserEntity entity = userRepository.findById(v2.getUserId())
|
||||
.orElseThrow(() -> new org.springframework.security.access.AccessDeniedException("User not found"));
|
||||
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
|
||||
boolean isAdmin = user.getPermissions().isAdmin();
|
||||
if (isAdmin) {
|
||||
var result = bookQueryService.getRecentBooksPage(true, page, size);
|
||||
return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size);
|
||||
}
|
||||
|
||||
java.util.Set<Long> libraryIds = user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
var result = bookQueryService.getRecentBooksByLibraryIdsPage(libraryIds, true, page, size);
|
||||
return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size);
|
||||
}
|
||||
|
||||
public String generateOpdsV2Navigation(HttpServletRequest request) {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
String rootPath = "/api/v2/opds";
|
||||
var root = new java.util.LinkedHashMap<String, Object>();
|
||||
|
||||
// metadata
|
||||
var meta = new java.util.LinkedHashMap<String, Object>();
|
||||
meta.put("title", "Booklore");
|
||||
root.put("metadata", meta);
|
||||
|
||||
// links: self, start, search
|
||||
var links = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "self",
|
||||
"href", rootPath,
|
||||
"type", "application/opds+json;profile=navigation"
|
||||
));
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "start",
|
||||
"href", rootPath,
|
||||
"type", "application/opds+json;profile=navigation"
|
||||
));
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "search",
|
||||
"href", rootPath + "/search.opds",
|
||||
"type", "application/opensearchdescription+xml"
|
||||
));
|
||||
root.put("links", links);
|
||||
|
||||
// navigation items
|
||||
var navigation = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of(
|
||||
"title", "All Books",
|
||||
"href", rootPath + "/catalog",
|
||||
"type", "application/opds+json;profile=acquisition"
|
||||
)));
|
||||
navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of(
|
||||
"title", "Recently Added",
|
||||
"href", rootPath + "/recent",
|
||||
"type", "application/opds+json;profile=acquisition"
|
||||
)));
|
||||
navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of(
|
||||
"title", "Libraries",
|
||||
"href", rootPath + "/libraries",
|
||||
"type", "application/opds+json;profile=navigation"
|
||||
)));
|
||||
navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of(
|
||||
"title", "Shelves",
|
||||
"href", rootPath + "/shelves",
|
||||
"type", "application/opds+json;profile=navigation"
|
||||
)));
|
||||
|
||||
|
||||
// Enrich with libraries and shelves for OPDS v2 users
|
||||
OpdsUserDetails details = authenticationService.getOpdsUser();
|
||||
if (details != null && details.getOpdsUserV2() != null) {
|
||||
Long userId = details.getOpdsUserV2().getUserId();
|
||||
BookLoreUserEntity entity = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new org.springframework.security.access.AccessDeniedException("User not found"));
|
||||
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
|
||||
|
||||
java.util.List<Library> libraries;
|
||||
try {
|
||||
libraries = libraryService.getLibraries();
|
||||
} catch (Exception ex) {
|
||||
libraries = user.getAssignedLibraries();
|
||||
}
|
||||
// Keep root clean: only references to collections; no inline lists
|
||||
if (libraries != null) {
|
||||
// optionally keep this empty or add a count in future
|
||||
}
|
||||
}
|
||||
root.put("navigation", navigation);
|
||||
|
||||
return mapper.writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed generating OPDS v2 navigation collection", e);
|
||||
throw new RuntimeException("Failed generating OPDS v2 navigation collection", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateOpdsV2LibrariesNavigation(HttpServletRequest request) {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
String rootPath = "/api/v2/opds";
|
||||
String selfPath = rootPath + "/libraries";
|
||||
var root = new java.util.LinkedHashMap<String, Object>();
|
||||
|
||||
// metadata
|
||||
var meta = new java.util.LinkedHashMap<String, Object>();
|
||||
meta.put("title", "Libraries");
|
||||
root.put("metadata", meta);
|
||||
|
||||
// links
|
||||
var links = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "self",
|
||||
"href", selfPath,
|
||||
"type", "application/opds+json;profile=navigation"
|
||||
));
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "start",
|
||||
"href", rootPath,
|
||||
"type", "application/opds+json;profile=navigation"
|
||||
));
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "search",
|
||||
"href", rootPath + "/search.opds",
|
||||
"type", "application/opensearchdescription+xml"
|
||||
));
|
||||
root.put("links", links);
|
||||
|
||||
// navigation list of libraries
|
||||
var navigation = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
|
||||
OpdsUserDetails details = authenticationService.getOpdsUser();
|
||||
if (details != null && details.getOpdsUserV2() != null) {
|
||||
Long userId = details.getOpdsUserV2().getUserId();
|
||||
BookLoreUserEntity entity = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new AccessDeniedException("User not found"));
|
||||
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
|
||||
|
||||
java.util.List<Library> libraries = (user.getPermissions() != null && user.getPermissions().isAdmin())
|
||||
? libraryService.getAllLibraries()
|
||||
: user.getAssignedLibraries();
|
||||
if (libraries != null) {
|
||||
for (Library lib : libraries) {
|
||||
navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of(
|
||||
"title", lib.getName(),
|
||||
"href", buildHref(rootPath + "/catalog", java.util.Map.of("libraryId", String.valueOf(lib.getId()))),
|
||||
"type", "application/opds+json;profile=acquisition"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root.put("navigation", navigation);
|
||||
return mapper.writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed generating OPDS v2 libraries navigation", e);
|
||||
throw new RuntimeException("Failed generating OPDS v2 libraries navigation", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public String generateOpdsV2ShelvesNavigation(HttpServletRequest request) {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
String rootPath = "/api/v2/opds";
|
||||
String selfPath = rootPath + "/shelves";
|
||||
var root = new java.util.LinkedHashMap<String, Object>();
|
||||
|
||||
var meta = new java.util.LinkedHashMap<String, Object>();
|
||||
meta.put("title", "Shelves");
|
||||
root.put("metadata", meta);
|
||||
|
||||
var links = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
links.add(java.util.Map.of("rel", "self", "href", selfPath, "type", "application/opds+json;profile=navigation"));
|
||||
links.add(java.util.Map.of("rel", "start", "href", rootPath, "type", "application/opds+json;profile=navigation"));
|
||||
links.add(java.util.Map.of("rel", "search", "href", rootPath + "/search.opds", "type", "application/opensearchdescription+xml"));
|
||||
root.put("links", links);
|
||||
|
||||
var navigation = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
OpdsUserDetails details = authenticationService.getOpdsUser();
|
||||
if (details != null && details.getOpdsUserV2() != null) {
|
||||
Long userId = details.getOpdsUserV2().getUserId();
|
||||
var shelves = shelfRepository.findByUserId(userId);
|
||||
if (shelves != null) {
|
||||
for (var shelf : shelves) {
|
||||
navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of(
|
||||
"title", shelf.getName(),
|
||||
"href", buildHref(rootPath + "/catalog", java.util.Map.of("shelfId", String.valueOf(shelf.getId()))),
|
||||
"type", "application/opds+json;profile=acquisition"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
root.put("navigation", navigation);
|
||||
return mapper.writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed generating OPDS v2 shelves navigation", e);
|
||||
throw new RuntimeException("Failed generating OPDS v2 shelves navigation", e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Book> getAllowedBooks(String queryParam) {
|
||||
OpdsUserDetails opdsUserDetails = authenticationService.getOpdsUser();
|
||||
OpdsUser opdsUser = opdsUserDetails.getOpdsUser();
|
||||
@@ -92,7 +355,12 @@ public class OpdsService {
|
||||
|
||||
private String extractVersionFromAcceptHeader(HttpServletRequest request) {
|
||||
var acceptHeader = request.getHeader("Accept");
|
||||
return (acceptHeader != null && acceptHeader.contains("version=2.0")) ? "2.0" : "1.2";
|
||||
if (acceptHeader == null) return "1.2";
|
||||
// Accept either explicit version or generic OPDS 2 media type
|
||||
if (acceptHeader.contains("version=2.0") || acceptHeader.contains("application/opds+json")) {
|
||||
return "2.0";
|
||||
}
|
||||
return "1.2";
|
||||
}
|
||||
|
||||
private String generateOpdsV1SearchDescription() {
|
||||
@@ -189,14 +457,148 @@ public class OpdsService {
|
||||
}
|
||||
}
|
||||
|
||||
private String generateOpdsV2Feed(List<Book> books) {
|
||||
// Placeholder for OPDS v2.0 feed implementation (similar structure as v1)
|
||||
return "OPDS v2.0 Feed is under construction";
|
||||
private String generateOpdsV2Feed(List<Book> content, long total, String basePath, java.util.Map<String,String> queryParams, int page, int size) {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
// Root collection
|
||||
var root = new java.util.LinkedHashMap<String, Object>();
|
||||
|
||||
// metadata with pagination
|
||||
var meta = new java.util.LinkedHashMap<String, Object>();
|
||||
meta.put("title", "Booklore Catalog");
|
||||
if (page < 1) page = 1;
|
||||
if (size < 1) size = 1;
|
||||
if (size > 200) size = 200;
|
||||
meta.put("itemsPerPage", size);
|
||||
meta.put("currentPage", page);
|
||||
meta.put("numberOfItems", total);
|
||||
root.put("metadata", meta);
|
||||
|
||||
// links
|
||||
var links = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "self",
|
||||
"href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page), "size", String.valueOf(size)))),
|
||||
"type", "application/opds+json;profile=acquisition"
|
||||
));
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "start",
|
||||
"href", "/api/v2/opds",
|
||||
"type", "application/opds+json;profile=navigation"
|
||||
));
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "search",
|
||||
"href", "/api/v2/opds/search.opds",
|
||||
"type", "application/opensearchdescription+xml"
|
||||
));
|
||||
if ((page - 1) > 0) {
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "previous",
|
||||
"href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page - 1), "size", String.valueOf(size)))),
|
||||
"type", "application/opds+json;profile=acquisition"
|
||||
));
|
||||
}
|
||||
if ((long) page * size < total) {
|
||||
links.add(java.util.Map.of(
|
||||
"rel", "next",
|
||||
"href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page + 1), "size", String.valueOf(size)))),
|
||||
"type", "application/opds+json;profile=acquisition"
|
||||
));
|
||||
}
|
||||
root.put("links", links);
|
||||
|
||||
// publications
|
||||
var pubs = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
for (Book book : content) {
|
||||
pubs.add(toPublicationMap(book));
|
||||
}
|
||||
root.put("publications", pubs);
|
||||
|
||||
return mapper.writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed generating OPDS v2 feed", e);
|
||||
throw new RuntimeException("Failed generating OPDS v2 feed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateOpdsV2Publication(HttpServletRequest request, long bookId) {
|
||||
List<Book> allowed = getAllowedBooks(null);
|
||||
Book target = allowed.stream().filter(b -> b.getId() != null && b.getId() == bookId).findFirst()
|
||||
.orElseThrow(() -> new AccessDeniedException("You are not allowed to access this resource"));
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
return mapper.writeValueAsString(toPublicationMap(target));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed generating OPDS v2 publication", e);
|
||||
throw new RuntimeException("Failed generating OPDS v2 publication", e);
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.Map<String, Object> toPublicationMap(Book book) {
|
||||
String base = "/api/v2/opds";
|
||||
var pub = new java.util.LinkedHashMap<String, Object>();
|
||||
var pm = new java.util.LinkedHashMap<String, Object>();
|
||||
String title = (book.getMetadata() != null ? book.getMetadata().getTitle() : book.getTitle());
|
||||
pm.put("title", title != null ? title : "Untitled");
|
||||
if (book.getMetadata() != null) {
|
||||
if (book.getMetadata().getLanguage() != null) {
|
||||
pm.put("language", book.getMetadata().getLanguage());
|
||||
}
|
||||
if (book.getMetadata().getIsbn13() != null) {
|
||||
pm.put("identifier", "urn:isbn:" + book.getMetadata().getIsbn13());
|
||||
} else if (book.getMetadata().getIsbn10() != null) {
|
||||
pm.put("identifier", "urn:isbn:" + book.getMetadata().getIsbn10());
|
||||
}
|
||||
if (book.getMetadata().getAuthors() != null && !book.getMetadata().getAuthors().isEmpty()) {
|
||||
var authors = book.getMetadata().getAuthors().stream()
|
||||
.map(a -> java.util.Map.of("name", a))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
pm.put("author", authors);
|
||||
}
|
||||
if (book.getMetadata().getDescription() != null) {
|
||||
pm.put("description", book.getMetadata().getDescription());
|
||||
}
|
||||
}
|
||||
pub.put("metadata", pm);
|
||||
|
||||
var plinks = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
String type = "application/" + fileMimeType(book);
|
||||
plinks.add(new java.util.LinkedHashMap<>(java.util.Map.of(
|
||||
"rel", "http://opds-spec.org/acquisition/open-access",
|
||||
"href", base + "/" + book.getId() + "/download",
|
||||
"type", type
|
||||
)));
|
||||
plinks.add(new java.util.LinkedHashMap<>(java.util.Map.of(
|
||||
"rel", "self",
|
||||
"href", base + "/publications/" + book.getId(),
|
||||
"type", "application/opds-publication+json"
|
||||
)));
|
||||
pub.put("links", plinks);
|
||||
|
||||
if (book.getMetadata() != null && book.getMetadata().getCoverUpdatedOn() != null) {
|
||||
var images = new java.util.ArrayList<java.util.Map<String, Object>>();
|
||||
String coverHref = base + "/" + book.getId() + "/cover?" + book.getMetadata().getCoverUpdatedOn();
|
||||
images.add(java.util.Map.of("href", coverHref, "type", "image/jpeg"));
|
||||
pub.put("images", images);
|
||||
}
|
||||
return pub;
|
||||
}
|
||||
|
||||
private String generateOpdsV2SearchDescription() {
|
||||
// Placeholder for OPDS v2.0 feed implementation (similar structure as v1)
|
||||
return "OPDS v2.0 Feed is under construction";
|
||||
return """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<LongName>Booklore catalog (OPDS 2)</LongName>
|
||||
<Description>Search the Booklore ebook catalog.</Description>
|
||||
<Url type="application/opds+json" template="/api/v2/opds/search{?q}"/>
|
||||
<Language>en-us</Language>
|
||||
<OutputEncoding>UTF-8</OutputEncoding>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
</OpenSearchDescription>
|
||||
""";
|
||||
}
|
||||
|
||||
|
||||
@@ -227,4 +629,107 @@ public class OpdsService {
|
||||
private String extractVersionFromRequest(HttpServletRequest request) {
|
||||
return (request.getRequestURI() != null && request.getRequestURI().startsWith("/api/v2/opds")) ? "v2" : "v1";
|
||||
}
|
||||
|
||||
private String urlEncode(String value) {
|
||||
try {
|
||||
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private int parseIntParam(HttpServletRequest request, String name, int defaultValue) {
|
||||
try {
|
||||
String v = request.getParameter(name);
|
||||
if (v == null || v.isBlank()) return defaultValue;
|
||||
return Integer.parseInt(v);
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildHref(String basePath, java.util.Map<String, String> params) {
|
||||
if (params == null || params.isEmpty()) return basePath;
|
||||
String query = params.entrySet().stream()
|
||||
.map(e -> urlEncode(e.getKey()) + "=" + urlEncode(e.getValue()))
|
||||
.collect(java.util.stream.Collectors.joining("&"));
|
||||
return basePath + (query.isEmpty() ? "" : ("?" + query));
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> mergeQuery(java.util.Map<String, String> base, java.util.Map<String, String> extra) {
|
||||
var map = new java.util.LinkedHashMap<String, String>();
|
||||
if (base != null) map.putAll(base);
|
||||
if (extra != null) map.putAll(extra);
|
||||
return map;
|
||||
}
|
||||
|
||||
private Long parseLongParam(HttpServletRequest request, String name, Long defaultValue) {
|
||||
try {
|
||||
String v = request.getParameter(name);
|
||||
if (v == null || v.isBlank()) return defaultValue;
|
||||
return Long.parseLong(v);
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private org.springframework.data.domain.Page<Book> getAllowedBooksPage(String queryParam, Long libraryId, Long shelfId, int page, int size) {
|
||||
OpdsUserDetails opdsUserDetails = authenticationService.getOpdsUser();
|
||||
OpdsUser opdsUser = opdsUserDetails.getOpdsUser();
|
||||
|
||||
if (opdsUser != null) {
|
||||
if (shelfId != null) {
|
||||
return bookQueryService.getAllBooksByShelfPage(shelfId, true, page, size);
|
||||
}
|
||||
if (libraryId != null) {
|
||||
return bookQueryService.getAllBooksByLibraryIdsPage(java.util.Set.of(libraryId), true, page, size);
|
||||
}
|
||||
if (queryParam != null && !queryParam.isBlank()) {
|
||||
return bookQueryService.searchBooksByMetadataPage(queryParam, page, size);
|
||||
}
|
||||
return bookQueryService.getAllBooksPage(true, page, size);
|
||||
}
|
||||
|
||||
OpdsUserV2 opdsUserV2 = opdsUserDetails.getOpdsUserV2();
|
||||
BookLoreUserEntity entity = userRepository.findById(opdsUserV2.getUserId())
|
||||
.orElseThrow(() -> new AccessDeniedException("User not found"));
|
||||
|
||||
if (!entity.getPermissions().isPermissionAccessOpds() && !entity.getPermissions().isPermissionAdmin()) {
|
||||
throw new AccessDeniedException("You are not allowed to access this resource");
|
||||
}
|
||||
|
||||
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
|
||||
boolean isAdmin = user.getPermissions().isAdmin();
|
||||
java.util.Set<Long> libraryIds = user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
|
||||
if (shelfId != null) {
|
||||
var shelf = shelfRepository.findById(shelfId).orElseThrow(() -> new AccessDeniedException("Shelf not found"));
|
||||
if (!shelf.getUser().getId().equals(user.getId()) && !isAdmin) {
|
||||
throw new AccessDeniedException("You are not allowed to access this shelf");
|
||||
}
|
||||
return bookQueryService.getAllBooksByShelfPage(shelfId, true, page, size);
|
||||
}
|
||||
|
||||
if (libraryId != null) {
|
||||
if (!isAdmin && !libraryIds.contains(libraryId)) {
|
||||
throw new AccessDeniedException("You are not allowed to access this library");
|
||||
}
|
||||
return (queryParam != null && !queryParam.isBlank())
|
||||
? bookQueryService.searchBooksByMetadataInLibrariesPage(queryParam, java.util.Set.of(libraryId), page, size)
|
||||
: bookQueryService.getAllBooksByLibraryIdsPage(java.util.Set.of(libraryId), true, page, size);
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
return (queryParam != null && !queryParam.isBlank())
|
||||
? bookQueryService.searchBooksByMetadataPage(queryParam, page, size)
|
||||
: bookQueryService.getAllBooksPage(true, page, size);
|
||||
}
|
||||
|
||||
return (queryParam != null && !queryParam.isBlank())
|
||||
? bookQueryService.searchBooksByMetadataInLibrariesPage(queryParam, libraryIds, page, size)
|
||||
: bookQueryService.getAllBooksByLibraryIdsPage(libraryIds, true, page, size);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+91
-13
@@ -35,6 +35,10 @@ class OpdsServiceTest {
|
||||
@Mock
|
||||
private BookLoreUserTransformer bookLoreUserTransformer;
|
||||
@Mock
|
||||
private com.adityachandel.booklore.service.library.LibraryService libraryService;
|
||||
@Mock
|
||||
private com.adityachandel.booklore.repository.ShelfRepository shelfRepository;
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
|
||||
@InjectMocks
|
||||
@@ -85,7 +89,7 @@ class OpdsServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateCatalogFeed_opdsV2Admin_callsGetAllBooks_and_returnsV2Placeholder() {
|
||||
void generateCatalogFeed_opdsV2Admin_callsGetAllBooks_and_returnsV2Json() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUser()).thenReturn(null);
|
||||
@@ -108,19 +112,21 @@ class OpdsServiceTest {
|
||||
|
||||
// Accept header drives v2 selection; do not stub request.getRequestURI() (unused here)
|
||||
when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0");
|
||||
// stub the backend call exercised by getAllowedBooks for admin + no query
|
||||
when(bookQueryService.getAllBooks(true)).thenReturn(List.of());
|
||||
// stub the backend call exercised by getAllowedBooksPage for admin + no query
|
||||
when(bookQueryService.getAllBooksPage(true, 1, 50)).thenReturn(new org.springframework.data.domain.PageImpl<>(List.of()));
|
||||
|
||||
String feed = service.generateCatalogFeed(request);
|
||||
|
||||
assertEquals("OPDS v2.0 Feed is under construction", feed);
|
||||
assertNotNull(feed);
|
||||
assertTrue(feed.trim().startsWith("{"));
|
||||
assertTrue(feed.contains("\"publications\""));
|
||||
verify(userRepository).findById(9L);
|
||||
verify(bookQueryService).getAllBooks(true);
|
||||
verify(bookQueryService).getAllBooksPage(true, 1, 50);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void generateSearchResults_opdsV2_callsSearch_and_returnsV2Placeholder() {
|
||||
void generateSearchResults_opdsV2_callsSearch_and_returnsV2Json() {
|
||||
// Setup v2 user (admin) so getAllowedBooks will call searchBooksByMetadata(query)
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
@@ -143,14 +149,16 @@ class OpdsServiceTest {
|
||||
// Accept header drives v2 selection; do not stub request.getRequestURI()
|
||||
when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0");
|
||||
|
||||
// getAllowedBooks will invoke searchBooksByMetadata for admin + query
|
||||
when(bookQueryService.searchBooksByMetadata("query")).thenReturn(List.of(mock(Book.class)));
|
||||
// getAllowedBooksPage will invoke searchBooksByMetadataPage for admin + query
|
||||
when(bookQueryService.searchBooksByMetadataPage("query", 1, 50)).thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(mock(Book.class))));
|
||||
|
||||
String feed = service.generateSearchResults(request, "query");
|
||||
|
||||
assertEquals("OPDS v2.0 Feed is under construction", feed);
|
||||
assertNotNull(feed);
|
||||
assertTrue(feed.trim().startsWith("{"));
|
||||
assertTrue(feed.contains("\"publications\""));
|
||||
verify(userRepository).findById(9L);
|
||||
verify(bookQueryService).searchBooksByMetadata("query");
|
||||
verify(bookQueryService).searchBooksByMetadataPage("query", 1, 50);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -208,9 +216,79 @@ class OpdsServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSearchDescription_opdsV2_returnsV2Placeholder() {
|
||||
void generateSearchDescription_opdsV2_returnsOpenSearchXml() {
|
||||
when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0");
|
||||
String desc = service.generateSearchDescription(request);
|
||||
assertEquals("OPDS v2.0 Feed is under construction", desc);
|
||||
assertNotNull(desc);
|
||||
assertTrue(desc.contains("<OpenSearchDescription"));
|
||||
assertTrue(desc.contains("application/opds+json"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateOpdsV2LibrariesNavigation_admin_listsAllLibraries() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUserV2()).thenReturn(mock(OpdsUserV2.class));
|
||||
when(details.getOpdsUserV2().getUserId()).thenReturn(1L);
|
||||
|
||||
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(entity));
|
||||
|
||||
BookLoreUser dto = mock(BookLoreUser.class);
|
||||
BookLoreUser.UserPermissions perms = mock(BookLoreUser.UserPermissions.class);
|
||||
when(perms.isAdmin()).thenReturn(true);
|
||||
when(dto.getPermissions()).thenReturn(perms);
|
||||
when(bookLoreUserTransformer.toDTO(entity)).thenReturn(dto);
|
||||
|
||||
Library lib1 = Library.builder().id(10L).name("AllLib1").build();
|
||||
Library lib2 = Library.builder().id(11L).name("AllLib2").build();
|
||||
when(libraryService.getAllLibraries()).thenReturn(List.of(lib1, lib2));
|
||||
|
||||
String json = service.generateOpdsV2LibrariesNavigation(request);
|
||||
|
||||
assertNotNull(json);
|
||||
assertTrue(json.contains("AllLib1"));
|
||||
assertTrue(json.contains("AllLib2"));
|
||||
assertTrue(json.contains("/api/v2/opds/catalog?libraryId=10"));
|
||||
assertTrue(json.contains("/api/v2/opds/catalog?libraryId=11"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateOpdsV2LibrariesNavigation_nonAdmin_listsAssignedLibraries() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUserV2()).thenReturn(mock(OpdsUserV2.class));
|
||||
when(details.getOpdsUserV2().getUserId()).thenReturn(2L);
|
||||
|
||||
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(entity));
|
||||
|
||||
BookLoreUser dto = mock(BookLoreUser.class);
|
||||
BookLoreUser.UserPermissions perms = mock(BookLoreUser.UserPermissions.class);
|
||||
when(perms.isAdmin()).thenReturn(false);
|
||||
when(dto.getPermissions()).thenReturn(perms);
|
||||
|
||||
Library assigned = Library.builder().id(20L).name("AssignedLib").build();
|
||||
when(dto.getAssignedLibraries()).thenReturn(List.of(assigned));
|
||||
when(bookLoreUserTransformer.toDTO(entity)).thenReturn(dto);
|
||||
|
||||
String json = service.generateOpdsV2LibrariesNavigation(request);
|
||||
|
||||
assertNotNull(json);
|
||||
assertTrue(json.contains("AssignedLib"));
|
||||
assertTrue(json.contains("/api/v2/opds/catalog?libraryId=20"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateOpdsV2LibrariesNavigation_withoutV2User_hasNoLibraryItems() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUserV2()).thenReturn(null); // no OPDS v2 principal
|
||||
|
||||
String json = service.generateOpdsV2LibrariesNavigation(request);
|
||||
|
||||
assertNotNull(json);
|
||||
assertTrue(json.contains("\"title\":\"Libraries\""));
|
||||
assertFalse(json.contains("/api/v2/opds/catalog?libraryId="));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user