Skip to content

Commit b00bc5f

Browse files
shiggsy365claude
andauthored
Add authors hierarchy and series metadata to OPDS feed (#1777)
This commit enhances the OPDS server with two major features: 1. Series Metadata in OPDS Feed: - Add series name and book number to OPDS book entries - Uses standard OPDS/EPUB 3 metadata format with <meta> tags - Includes belongs-to-collection and group-position properties 2. Authors Navigation Hierarchy: - Add new /authors endpoint for browsing books by author - Implement authors list navigation feed with alphabetical sorting - Add author filtering to catalog feed via ?author parameter - Support library-based access control for author lists - Add authors link to root OPDS navigation Changes: - OpdsFeedService: Add series metadata to appendMetadata(), add generateAuthorsNavigation() - OpdsController: Add getAuthorsNavigation() endpoint - OpdsBookService: Add getDistinctAuthors() and getBooksByAuthorName() methods - BookOpdsRepository: Add queries for distinct authors and books by author name - Updated generateCatalogFeed() to support author parameter - Updated determineFeedTitle() and determineFeedId() to handle author context Co-authored-by: Claude <[email protected]>
1 parent afe9c59 commit b00bc5f

File tree

4 files changed

+189
-4
lines changed

4 files changed

+189
-4
lines changed

booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ public ResponseEntity<String> getMagicShelvesNavigation(@Parameter(hidden = true
9797
.body(feed);
9898
}
9999

100+
@Operation(summary = "Get OPDS authors navigation", description = "Retrieve the OPDS authors navigation feed.")
101+
@ApiResponse(responseCode = "200", description = "Authors navigation feed returned successfully")
102+
@GetMapping(value = "/authors", produces = OPDS_CATALOG_MEDIA_TYPE)
103+
public ResponseEntity<String> getAuthorsNavigation(@Parameter(hidden = true) HttpServletRequest request) {
104+
String feed = opdsFeedService.generateAuthorsNavigation(request);
105+
return ResponseEntity.ok()
106+
.contentType(MediaType.parseMediaType(OPDS_CATALOG_MEDIA_TYPE))
107+
.body(feed);
108+
}
109+
100110
@Operation(summary = "Get OPDS catalog feed", description = "Retrieve the OPDS acquisition catalog feed.")
101111
@ApiResponse(responseCode = "200", description = "Catalog feed returned successfully")
102112
@GetMapping(value = "/catalog", produces = OPDS_ACQUISITION_MEDIA_TYPE)

booklore-api/src/main/java/com/adityachandel/booklore/repository/BookOpdsRepository.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,52 @@ OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
122122

123123
@Query(value = "SELECT b.id FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false) ORDER BY function('RAND')", nativeQuery = false)
124124
List<Long> findRandomBookIdsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
125+
126+
// ============================================
127+
// AUTHORS - Distinct Authors List
128+
// ============================================
129+
130+
@Query("""
131+
SELECT DISTINCT a FROM AuthorEntity a
132+
JOIN a.bookMetadataEntityList m
133+
JOIN m.book b
134+
WHERE (b.deleted IS NULL OR b.deleted = false)
135+
ORDER BY a.name
136+
""")
137+
List<com.adityachandel.booklore.model.entity.AuthorEntity> findDistinctAuthors();
138+
139+
@Query("""
140+
SELECT DISTINCT a FROM AuthorEntity a
141+
JOIN a.bookMetadataEntityList m
142+
JOIN m.book b
143+
WHERE (b.deleted IS NULL OR b.deleted = false)
144+
AND b.library.id IN :libraryIds
145+
ORDER BY a.name
146+
""")
147+
List<com.adityachandel.booklore.model.entity.AuthorEntity> findDistinctAuthorsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
148+
149+
// ============================================
150+
// BOOKS BY AUTHOR - Two Query Pattern
151+
// ============================================
152+
153+
@Query("""
154+
SELECT DISTINCT b.id FROM BookEntity b
155+
JOIN b.metadata m
156+
JOIN m.authors a
157+
WHERE a.name = :authorName
158+
AND (b.deleted IS NULL OR b.deleted = false)
159+
ORDER BY b.addedOn DESC
160+
""")
161+
Page<Long> findBookIdsByAuthorName(@Param("authorName") String authorName, Pageable pageable);
162+
163+
@Query("""
164+
SELECT DISTINCT b.id FROM BookEntity b
165+
JOIN b.metadata m
166+
JOIN m.authors a
167+
WHERE a.name = :authorName
168+
AND b.library.id IN :libraryIds
169+
AND (b.deleted IS NULL OR b.deleted = false)
170+
ORDER BY b.addedOn DESC
171+
""")
172+
Page<Long> findBookIdsByAuthorNameAndLibraryIds(@Param("authorName") String authorName, @Param("libraryIds") Collection<Long> libraryIds, Pageable pageable);
125173
}

booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,68 @@ public List<Book> getRandomBooks(Long userId, int count) {
157157
return books.stream().map(bookMapper::toBook).toList();
158158
}
159159

160+
public List<String> getDistinctAuthors(Long userId) {
161+
if (userId == null) {
162+
return List.of();
163+
}
164+
165+
BookLoreUserEntity entity = userRepository.findById(userId)
166+
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(userId));
167+
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
168+
169+
List<com.adityachandel.booklore.model.entity.AuthorEntity> authors;
170+
171+
if (user.getPermissions().isAdmin()) {
172+
authors = bookOpdsRepository.findDistinctAuthors();
173+
} else {
174+
Set<Long> libraryIds = user.getAssignedLibraries().stream()
175+
.map(Library::getId)
176+
.collect(Collectors.toSet());
177+
authors = bookOpdsRepository.findDistinctAuthorsByLibraryIds(libraryIds);
178+
}
179+
180+
return authors.stream()
181+
.map(com.adityachandel.booklore.model.entity.AuthorEntity::getName)
182+
.filter(Objects::nonNull)
183+
.distinct()
184+
.sorted()
185+
.toList();
186+
}
187+
188+
public Page<Book> getBooksByAuthorName(Long userId, String authorName, int page, int size) {
189+
if (userId == null) {
190+
throw ApiError.FORBIDDEN.createException("Authentication required");
191+
}
192+
193+
BookLoreUserEntity entity = userRepository.findById(userId)
194+
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(userId));
195+
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
196+
197+
Pageable pageable = PageRequest.of(Math.max(page, 0), size);
198+
199+
if (user.getPermissions().isAdmin()) {
200+
Page<Long> idPage = bookOpdsRepository.findBookIdsByAuthorName(authorName, pageable);
201+
if (idPage.isEmpty()) {
202+
return new PageImpl<>(List.of(), pageable, 0);
203+
}
204+
List<BookEntity> books = bookOpdsRepository.findAllWithMetadataByIds(idPage.getContent());
205+
return createPageFromEntities(books, idPage, pageable);
206+
}
207+
208+
Set<Long> libraryIds = user.getAssignedLibraries().stream()
209+
.map(Library::getId)
210+
.collect(Collectors.toSet());
211+
212+
Page<Long> idPage = bookOpdsRepository.findBookIdsByAuthorNameAndLibraryIds(authorName, libraryIds, pageable);
213+
if (idPage.isEmpty()) {
214+
return new PageImpl<>(List.of(), pageable, 0);
215+
}
216+
217+
List<BookEntity> books = bookOpdsRepository.findAllWithMetadataByIdsAndLibraryIds(idPage.getContent(), libraryIds);
218+
Page<Book> booksPage = createPageFromEntities(books, idPage, pageable);
219+
return applyBookFilters(booksPage, userId);
220+
}
221+
160222
private Page<Book> getAllBooksPageInternal(int page, int size) {
161223
Pageable pageable = PageRequest.of(Math.max(page, 0), size);
162224

booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ public String generateRootNavigation(HttpServletRequest request) {
9090
</entry>
9191
""".formatted(now()));
9292

93+
feed.append("""
94+
<entry>
95+
<title>Authors</title>
96+
<id>urn:booklore:navigation:authors</id>
97+
<updated>%s</updated>
98+
<link rel="subsection" href="/api/v1/opds/authors" type="application/atom+xml;profile=opds-catalog;kind=navigation"/>
99+
<content type="text">Browse books by author</content>
100+
</entry>
101+
""".formatted(now()));
102+
93103
feed.append("""
94104
<entry>
95105
<title>Surprise Me</title>
@@ -223,11 +233,49 @@ public String generateMagicShelvesNavigation(HttpServletRequest request) {
223233
return feed.toString();
224234
}
225235

236+
public String generateAuthorsNavigation(HttpServletRequest request) {
237+
Long userId = getUserId();
238+
List<String> authors = opdsBookService.getDistinctAuthors(userId);
239+
240+
var feed = new StringBuilder("""
241+
<?xml version="1.0" encoding="UTF-8"?>
242+
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:opds="http://opds-spec.org/2010/catalog">
243+
<id>urn:booklore:navigation:authors</id>
244+
<title>Authors</title>
245+
<updated>%s</updated>
246+
<link rel="self" href="/api/v1/opds/authors" type="application/atom+xml;profile=opds-catalog;kind=navigation"/>
247+
<link rel="start" href="/api/v1/opds" type="application/atom+xml;profile=opds-catalog;kind=navigation"/>
248+
<link rel="search" type="application/opensearchdescription+xml" title="Search" href="/api/v1/opds/search.opds"/>
249+
""".formatted(now()));
250+
251+
for (String author : authors) {
252+
feed.append("""
253+
<entry>
254+
<title>%s</title>
255+
<id>urn:booklore:author:%s</id>
256+
<updated>%s</updated>
257+
<link rel="subsection" href="%s" type="application/atom+xml;profile=opds-catalog;kind=acquisition"/>
258+
<content type="text">Books by %s</content>
259+
</entry>
260+
""".formatted(
261+
escapeXml(author),
262+
escapeXml(author),
263+
now(),
264+
escapeXml("/api/v1/opds/catalog?author=" + java.net.URLEncoder.encode(author, java.nio.charset.StandardCharsets.UTF_8)),
265+
escapeXml(author)
266+
));
267+
}
268+
269+
feed.append("</feed>");
270+
return feed.toString();
271+
}
272+
226273
public String generateCatalogFeed(HttpServletRequest request) {
227274
Long libraryId = parseLongParam(request, "libraryId", null);
228275
Long shelfId = parseLongParam(request, "shelfId", null);
229276
Long magicShelfId = parseLongParam(request, "magicShelfId", null);
230277
String query = request.getParameter("q");
278+
String author = request.getParameter("author");
231279
int page = Math.max(1, parseLongParam(request, "page", 1L).intValue());
232280
int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE);
233281

@@ -236,12 +284,14 @@ public String generateCatalogFeed(HttpServletRequest request) {
236284

237285
if (magicShelfId != null) {
238286
booksPage = magicShelfBookService.getBooksByMagicShelfId(userId, magicShelfId, page - 1, size);
287+
} else if (author != null && !author.isBlank()) {
288+
booksPage = opdsBookService.getBooksByAuthorName(userId, author, page - 1, size);
239289
} else {
240290
booksPage = opdsBookService.getBooksPage(userId, query, libraryId, shelfId, page - 1, size);
241291
}
242292

243-
String feedTitle = determineFeedTitle(libraryId, shelfId, magicShelfId);
244-
String feedId = determineFeedId(libraryId, shelfId, magicShelfId);
293+
String feedTitle = determineFeedTitle(libraryId, shelfId, magicShelfId, author);
294+
String feedId = determineFeedId(libraryId, shelfId, magicShelfId, author);
245295

246296
var feed = new StringBuilder("""
247297
<?xml version="1.0" encoding="UTF-8"?>
@@ -427,6 +477,15 @@ private void appendMetadata(StringBuilder feed, Book book) {
427477
if (meta.getIsbn10() != null) {
428478
feed.append(" <dc:identifier>urn:isbn:").append(escapeXml(meta.getIsbn10())).append("</dc:identifier>\n");
429479
}
480+
// Series metadata
481+
if (meta.getSeriesName() != null) {
482+
feed.append(" <meta property=\"belongs-to-collection\" id=\"series\">")
483+
.append(escapeXml(meta.getSeriesName())).append("</meta>\n");
484+
if (meta.getSeriesNumber() != null) {
485+
feed.append(" <meta property=\"group-position\" refines=\"#series\">")
486+
.append(meta.getSeriesNumber()).append("</meta>\n");
487+
}
488+
}
430489
}
431490

432491
private void appendLinks(StringBuilder feed, Book book) {
@@ -443,7 +502,7 @@ private void appendLinks(StringBuilder feed, Book book) {
443502
}
444503
}
445504

446-
private String determineFeedTitle(Long libraryId, Long shelfId, Long magicShelfId) {
505+
private String determineFeedTitle(Long libraryId, Long shelfId, Long magicShelfId, String author) {
447506
if (magicShelfId != null) {
448507
return magicShelfBookService.getMagicShelfName(magicShelfId);
449508
}
@@ -453,10 +512,13 @@ private String determineFeedTitle(Long libraryId, Long shelfId, Long magicShelfI
453512
if (libraryId != null) {
454513
return opdsBookService.getLibraryName(libraryId);
455514
}
515+
if (author != null && !author.isBlank()) {
516+
return "Books by " + author;
517+
}
456518
return "Booklore Catalog";
457519
}
458520

459-
private String determineFeedId(Long libraryId, Long shelfId, Long magicShelfId) {
521+
private String determineFeedId(Long libraryId, Long shelfId, Long magicShelfId, String author) {
460522
if (magicShelfId != null) {
461523
return "urn:booklore:magic-shelf:" + magicShelfId;
462524
}
@@ -466,6 +528,9 @@ private String determineFeedId(Long libraryId, Long shelfId, Long magicShelfId)
466528
if (libraryId != null) {
467529
return "urn:booklore:library:" + libraryId;
468530
}
531+
if (author != null && !author.isBlank()) {
532+
return "urn:booklore:author:" + author;
533+
}
469534
return "urn:booklore:catalog";
470535
}
471536

0 commit comments

Comments
 (0)