feat(opds): add series hierarchy (#1837)

Co-authored-by: WorldTeacher <admin@theprivateserver.de>
This commit is contained in:
WorldTeacher
2025-12-13 23:46:59 +01:00
committed by GitHub
parent b55b684125
commit b64c30f3bc
4 changed files with 174 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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