diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java index 107bc59a..bd1a11f5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java @@ -107,6 +107,16 @@ public class OpdsController { .body(feed); } + @Operation(summary = "Get OPDS series navigation", description = "Retrieve the OPDS series navigation feed.") + @ApiResponse(responseCode = "200", description = "Series navigation feed returned successfully") + @GetMapping(value = "/series", produces = OPDS_CATALOG_MEDIA_TYPE) + public ResponseEntity getSeriesNavigation(@Parameter(hidden = true) HttpServletRequest request) { + String feed = opdsFeedService.generateSeriesNavigation(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(OPDS_CATALOG_MEDIA_TYPE)) + .body(feed); + } + @Operation(summary = "Get OPDS catalog feed", description = "Retrieve the OPDS acquisition catalog feed.") @ApiResponse(responseCode = "200", description = "Catalog feed returned successfully") @GetMapping(value = "/catalog", produces = OPDS_ACQUISITION_MEDIA_TYPE) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java index 8dd9fcb9..4b93afa3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java @@ -162,4 +162,52 @@ public interface BookOpdsRepository extends JpaRepository, Jpa ORDER BY b.addedOn DESC """) Page findBookIdsByAuthorNameAndLibraryIds(@Param("authorName") String authorName, @Param("libraryIds") Collection libraryIds, Pageable pageable); + + // ============================================ + // SERIES - Distinct Series List + // ============================================ + + @Query(""" + SELECT DISTINCT m.seriesName FROM BookMetadataEntity m + JOIN m.book b + WHERE (b.deleted IS NULL OR b.deleted = false) + AND m.seriesName IS NOT NULL + AND m.seriesName != '' + ORDER BY m.seriesName + """) + List findDistinctSeries(); + + @Query(""" + SELECT DISTINCT m.seriesName FROM BookMetadataEntity m + JOIN m.book b + WHERE (b.deleted IS NULL OR b.deleted = false) + AND b.library.id IN :libraryIds + AND m.seriesName IS NOT NULL + AND m.seriesName != '' + ORDER BY m.seriesName + """) + List findDistinctSeriesByLibraryIds(@Param("libraryIds") Collection libraryIds); + + // ============================================ + // BOOKS BY SERIES - Two Query Pattern (sorted by series number) + // ============================================ + + @Query(""" + SELECT DISTINCT b.id FROM BookEntity b + JOIN b.metadata m + WHERE m.seriesName = :seriesName + AND (b.deleted IS NULL OR b.deleted = false) + ORDER BY COALESCE(m.seriesNumber, 999999), b.addedOn DESC + """) + Page findBookIdsBySeriesName(@Param("seriesName") String seriesName, Pageable pageable); + + @Query(""" + SELECT DISTINCT b.id FROM BookEntity b + JOIN b.metadata m + WHERE m.seriesName = :seriesName + AND b.library.id IN :libraryIds + AND (b.deleted IS NULL OR b.deleted = false) + ORDER BY COALESCE(m.seriesNumber, 999999), b.addedOn DESC + """) + Page findBookIdsBySeriesNameAndLibraryIds(@Param("seriesName") String seriesName, @Param("libraryIds") Collection libraryIds, Pageable pageable); } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java index 6a9c05fc..ea39f70c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java @@ -202,7 +202,7 @@ public class OpdsBookService { if (idPage.isEmpty()) { return new PageImpl<>(List.of(), pageable, 0); } - List books = bookOpdsRepository.findAllWithMetadataByIds(idPage.getContent()); + List books = bookOpdsRepository.findAllWithFullMetadataByIds(idPage.getContent()); return createPageFromEntities(books, idPage, pageable); } @@ -215,7 +215,61 @@ public class OpdsBookService { return new PageImpl<>(List.of(), pageable, 0); } - List books = bookOpdsRepository.findAllWithMetadataByIdsAndLibraryIds(idPage.getContent(), libraryIds); + List books = bookOpdsRepository.findAllWithFullMetadataByIdsAndLibraryIds(idPage.getContent(), libraryIds); + Page booksPage = createPageFromEntities(books, idPage, pageable); + return applyBookFilters(booksPage, userId); + } + + public List getDistinctSeries(Long userId) { + if (userId == null) { + return List.of(); + } + + BookLoreUserEntity entity = userRepository.findById(userId) + .orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(userId)); + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + + if (user.getPermissions().isAdmin()) { + return bookOpdsRepository.findDistinctSeries(); + } + + Set libraryIds = user.getAssignedLibraries().stream() + .map(Library::getId) + .collect(Collectors.toSet()); + + return bookOpdsRepository.findDistinctSeriesByLibraryIds(libraryIds); + } + + public Page getBooksBySeriesName(Long userId, String seriesName, int page, int size) { + if (userId == null) { + throw ApiError.FORBIDDEN.createException("Authentication required"); + } + + BookLoreUserEntity entity = userRepository.findById(userId) + .orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(userId)); + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + + Pageable pageable = PageRequest.of(Math.max(page, 0), size); + + if (user.getPermissions().isAdmin()) { + Page idPage = bookOpdsRepository.findBookIdsBySeriesName(seriesName, pageable); + if (idPage.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + List books = bookOpdsRepository.findAllWithFullMetadataByIds(idPage.getContent()); + return createPageFromEntities(books, idPage, pageable); + } + + Set libraryIds = user.getAssignedLibraries().stream() + .map(Library::getId) + .collect(Collectors.toSet()); + + Page idPage = bookOpdsRepository.findBookIdsBySeriesNameAndLibraryIds(seriesName, libraryIds, pageable); + if (idPage.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + + List books = bookOpdsRepository.findAllWithFullMetadataByIdsAndLibraryIds(idPage.getContent(), libraryIds); Page booksPage = createPageFromEntities(books, idPage, pageable); return applyBookFilters(booksPage, userId); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java index 80f2ed43..3372cbce 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java @@ -100,6 +100,16 @@ public class OpdsFeedService { """.formatted(now())); + feed.append(""" + + Series + urn:booklore:navigation:series + %s + + Browse books by series + + """.formatted(now())); + feed.append(""" Surprise Me @@ -270,12 +280,50 @@ public class OpdsFeedService { return feed.toString(); } + public String generateSeriesNavigation(HttpServletRequest request) { + Long userId = getUserId(); + List seriesList = opdsBookService.getDistinctSeries(userId); + + var feed = new StringBuilder(""" + + + urn:booklore:navigation:series + Series + %s + + + + """.formatted(now())); + + for (String series : seriesList) { + feed.append(""" + + %s + urn:booklore:series:%s + %s + + Books in the %s series + + """.formatted( + escapeXml(series), + escapeXml(series), + now(), + escapeXml("/api/v1/opds/catalog?series=" + java.net.URLEncoder.encode(series, java.nio.charset.StandardCharsets.UTF_8)), + escapeXml(series) + )); + } + + feed.append(""); + return feed.toString(); + } + public String generateCatalogFeed(HttpServletRequest request) { Long libraryId = parseLongParam(request, "libraryId", null); Long shelfId = parseLongParam(request, "shelfId", null); Long magicShelfId = parseLongParam(request, "magicShelfId", null); String query = request.getParameter("q"); String author = request.getParameter("author"); + String series = request.getParameter("series"); int page = Math.max(1, parseLongParam(request, "page", 1L).intValue()); int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE); @@ -286,12 +334,14 @@ public class OpdsFeedService { booksPage = magicShelfBookService.getBooksByMagicShelfId(userId, magicShelfId, page - 1, size); } else if (author != null && !author.isBlank()) { booksPage = opdsBookService.getBooksByAuthorName(userId, author, page - 1, size); + } else if (series != null && !series.isBlank()) { + booksPage = opdsBookService.getBooksBySeriesName(userId, series, page - 1, size); } else { booksPage = opdsBookService.getBooksPage(userId, query, libraryId, shelfId, page - 1, size); } - String feedTitle = determineFeedTitle(libraryId, shelfId, magicShelfId, author); - String feedId = determineFeedId(libraryId, shelfId, magicShelfId, author); + String feedTitle = determineFeedTitle(libraryId, shelfId, magicShelfId, author, series); + String feedId = determineFeedId(libraryId, shelfId, magicShelfId, author, series); var feed = new StringBuilder(""" @@ -502,7 +552,7 @@ public class OpdsFeedService { } } - private String determineFeedTitle(Long libraryId, Long shelfId, Long magicShelfId, String author) { + private String determineFeedTitle(Long libraryId, Long shelfId, Long magicShelfId, String author, String series) { if (magicShelfId != null) { return magicShelfBookService.getMagicShelfName(magicShelfId); } @@ -515,10 +565,13 @@ public class OpdsFeedService { if (author != null && !author.isBlank()) { return "Books by " + author; } + if (series != null && !series.isBlank()) { + return series + " series"; + } return "Booklore Catalog"; } - private String determineFeedId(Long libraryId, Long shelfId, Long magicShelfId, String author) { + private String determineFeedId(Long libraryId, Long shelfId, Long magicShelfId, String author, String series) { if (magicShelfId != null) { return "urn:booklore:magic-shelf:" + magicShelfId; } @@ -531,6 +584,9 @@ public class OpdsFeedService { if (author != null && !author.isBlank()) { return "urn:booklore:author:" + author; } + if (series != null && !series.isBlank()) { + return "urn:booklore:series:" + series; + } return "urn:booklore:catalog"; }