mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
feat(opds): add series hierarchy (#1837)
Co-authored-by: WorldTeacher <admin@theprivateserver.de>
This commit is contained in:
@@ -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<String> 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)
|
||||
|
||||
@@ -162,4 +162,52 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
|
||||
ORDER BY b.addedOn DESC
|
||||
""")
|
||||
Page<Long> findBookIdsByAuthorNameAndLibraryIds(@Param("authorName") String authorName, @Param("libraryIds") Collection<Long> 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<String> 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<String> findDistinctSeriesByLibraryIds(@Param("libraryIds") Collection<Long> 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<Long> 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<Long> findBookIdsBySeriesNameAndLibraryIds(@Param("seriesName") String seriesName, @Param("libraryIds") Collection<Long> libraryIds, Pageable pageable);
|
||||
}
|
||||
@@ -202,7 +202,7 @@ public class OpdsBookService {
|
||||
if (idPage.isEmpty()) {
|
||||
return new PageImpl<>(List.of(), pageable, 0);
|
||||
}
|
||||
List<BookEntity> books = bookOpdsRepository.findAllWithMetadataByIds(idPage.getContent());
|
||||
List<BookEntity> 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<BookEntity> books = bookOpdsRepository.findAllWithMetadataByIdsAndLibraryIds(idPage.getContent(), libraryIds);
|
||||
List<BookEntity> books = bookOpdsRepository.findAllWithFullMetadataByIdsAndLibraryIds(idPage.getContent(), libraryIds);
|
||||
Page<Book> booksPage = createPageFromEntities(books, idPage, pageable);
|
||||
return applyBookFilters(booksPage, userId);
|
||||
}
|
||||
|
||||
public List<String> 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<Long> libraryIds = user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
return bookOpdsRepository.findDistinctSeriesByLibraryIds(libraryIds);
|
||||
}
|
||||
|
||||
public Page<Book> 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<Long> idPage = bookOpdsRepository.findBookIdsBySeriesName(seriesName, pageable);
|
||||
if (idPage.isEmpty()) {
|
||||
return new PageImpl<>(List.of(), pageable, 0);
|
||||
}
|
||||
List<BookEntity> books = bookOpdsRepository.findAllWithFullMetadataByIds(idPage.getContent());
|
||||
return createPageFromEntities(books, idPage, pageable);
|
||||
}
|
||||
|
||||
Set<Long> libraryIds = user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Page<Long> idPage = bookOpdsRepository.findBookIdsBySeriesNameAndLibraryIds(seriesName, libraryIds, pageable);
|
||||
if (idPage.isEmpty()) {
|
||||
return new PageImpl<>(List.of(), pageable, 0);
|
||||
}
|
||||
|
||||
List<BookEntity> books = bookOpdsRepository.findAllWithFullMetadataByIdsAndLibraryIds(idPage.getContent(), libraryIds);
|
||||
Page<Book> booksPage = createPageFromEntities(books, idPage, pageable);
|
||||
return applyBookFilters(booksPage, userId);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,16 @@ public class OpdsFeedService {
|
||||
</entry>
|
||||
""".formatted(now()));
|
||||
|
||||
feed.append("""
|
||||
<entry>
|
||||
<title>Series</title>
|
||||
<id>urn:booklore:navigation:series</id>
|
||||
<updated>%s</updated>
|
||||
<link rel="subsection" href="/api/v1/opds/series" type="application/atom+xml;profile=opds-catalog;kind=navigation"/>
|
||||
<content type="text">Browse books by series</content>
|
||||
</entry>
|
||||
""".formatted(now()));
|
||||
|
||||
feed.append("""
|
||||
<entry>
|
||||
<title>Surprise Me</title>
|
||||
@@ -270,12 +280,50 @@ public class OpdsFeedService {
|
||||
return feed.toString();
|
||||
}
|
||||
|
||||
public String generateSeriesNavigation(HttpServletRequest request) {
|
||||
Long userId = getUserId();
|
||||
List<String> seriesList = opdsBookService.getDistinctSeries(userId);
|
||||
|
||||
var feed = new StringBuilder("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:opds="http://opds-spec.org/2010/catalog">
|
||||
<id>urn:booklore:navigation:series</id>
|
||||
<title>Series</title>
|
||||
<updated>%s</updated>
|
||||
<link rel="self" href="/api/v1/opds/series" type="application/atom+xml;profile=opds-catalog;kind=navigation"/>
|
||||
<link rel="start" href="/api/v1/opds" type="application/atom+xml;profile=opds-catalog;kind=navigation"/>
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="Search" href="/api/v1/opds/search.opds"/>
|
||||
""".formatted(now()));
|
||||
|
||||
for (String series : seriesList) {
|
||||
feed.append("""
|
||||
<entry>
|
||||
<title>%s</title>
|
||||
<id>urn:booklore:series:%s</id>
|
||||
<updated>%s</updated>
|
||||
<link rel="subsection" href="%s" type="application/atom+xml;profile=opds-catalog;kind=acquisition"/>
|
||||
<content type="text">Books in the %s series</content>
|
||||
</entry>
|
||||
""".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("</feed>");
|
||||
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("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user