fix(opds): wire up catalog pagination links and OpenSearch search
The device catalog feed was unusable on paged OPDS clients such as
KOReader: it sliced results into pages but never advertised how to reach
the next page, so clients could only ever fetch the first page (~50 books)
and could not search the catalog.
GetDeviceCatalog:
- Emit the full set of OPDS pagination link relations (self, start, first,
previous, next, last) pointing at catalog?page=N&per_page=M, with the
device auth token appended for path-based auth.
- Emit OpenSearch totalResults/itemsPerPage/startIndex metadata.
- Point rel=search at the OpenSearch description (correct MIME type).
SearchDeviceCatalog now branches on the q parameter:
- No q: return an OpenSearch description document whose Url template
contains the {searchTerms} placeholder, so clients can formulate a query.
- With q: return the existing acquisition results feed, now including
totalResults.
A pure addCatalogPaginationLinks helper holds the page/URL logic so it can
be unit tested without a database. New handler tests cover middle/first/
last/single/empty pages (correct presence of next/previous) and token
appending.
Ordering is intentionally left unchanged (created_at DESC, grouped by
library).
This commit is contained in:
+75
-15
@@ -67,6 +67,42 @@ func appendToken(url, token string) string {
|
||||
return url + "?token=" + token
|
||||
}
|
||||
|
||||
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
||||
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||
|
||||
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
|
||||
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
||||
// itemsPerPage, startIndex) to a feed based on the current page position.
|
||||
// catalogBase is the device catalog URL without query parameters. The token
|
||||
// (device auth) is appended to every generated link.
|
||||
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
|
||||
totalPages := 0
|
||||
if totalItems > 0 {
|
||||
totalPages = (totalItems + perPageNum - 1) / perPageNum
|
||||
}
|
||||
startIdx := (pageNum - 1) * perPageNum
|
||||
|
||||
pagedURL := func(page int) string {
|
||||
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
|
||||
}
|
||||
|
||||
// self reflects the current page; start/first point to the first page
|
||||
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
|
||||
feed.AddLink(pagedURL(1), catalogMediaType, "start")
|
||||
feed.AddLink(pagedURL(1), catalogMediaType, "first")
|
||||
if totalPages > 0 {
|
||||
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
|
||||
}
|
||||
if pageNum > 1 {
|
||||
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
|
||||
}
|
||||
if pageNum < totalPages {
|
||||
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
|
||||
}
|
||||
|
||||
feed.SetPagination(totalItems, perPageNum, startIdx+1)
|
||||
}
|
||||
|
||||
// resolveMimeType returns the mime type for a media item, preferring the stored
|
||||
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
||||
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
||||
@@ -206,14 +242,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
"Bookhoard Library",
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
// Feed links, including OPDS pagination links (first/previous/next/last) and
|
||||
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
|
||||
token := h.getAuthToken(c)
|
||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
||||
|
||||
// OpenSearch: the search link points to an OpenSearch description document
|
||||
// (served by the same /search endpoint when no query is supplied) so that
|
||||
// OPDS clients like KOReader can discover how to formulate search requests.
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
// Add entries
|
||||
for _, item := range allItems {
|
||||
@@ -286,16 +325,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
// SearchDeviceCatalog searches the OPDS catalog for a device
|
||||
// SearchDeviceCatalog searches the OPDS catalog for a device.
|
||||
//
|
||||
// When no "q" query parameter is supplied it returns an OpenSearch description
|
||||
// document (application/opensearchdescription+xml) so that OPDS clients such as
|
||||
// KOReader can discover the search URL template (which contains the
|
||||
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
|
||||
// acquisition feed of matching books.
|
||||
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
query := c.QueryParam("q")
|
||||
|
||||
if query == "" {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
||||
}
|
||||
|
||||
// Get base URLs
|
||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
@@ -316,12 +356,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
|
||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||
}
|
||||
|
||||
token := h.getAuthToken(c)
|
||||
|
||||
// No query: serve the OpenSearch description document so clients can learn
|
||||
// the search template (contains the {searchTerms} placeholder).
|
||||
if query == "" {
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||
desc := opds.NewSearchDescription(
|
||||
"Bookhoard",
|
||||
"Search the Bookhoard library",
|
||||
searchURL,
|
||||
)
|
||||
xmlString, err := desc.GenerateXMLString()
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
|
||||
}
|
||||
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
// Search media items
|
||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
@@ -340,12 +398,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
token := h.getAuthToken(c)
|
||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
feed.AddLink(catalogURL, catalogMediaType, "start")
|
||||
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
feed.AddLink(searchURL, catalogMediaType, "self")
|
||||
|
||||
// OpenSearch paging metadata (search results are a single page)
|
||||
feed.SetPagination(len(allItems), len(allItems), 1)
|
||||
|
||||
// Add entries (same as catalog)
|
||||
userUUID := uuid.UUID(userID)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/opds"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// rels collects the rel attributes of all links currently on the feed.
|
||||
func rels(feed *opds.Feed) []string {
|
||||
out := make([]string, 0, len(feed.Links))
|
||||
for _, l := range feed.Links {
|
||||
out = append(out, l.Rel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsRel(feed *opds.Feed, rel string) bool {
|
||||
for _, l := range feed.Links {
|
||||
if l.Rel == rel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_MiddlePage(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
// 1814 items, 50 per page => 37 pages; on page 2
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 2, 50, 1814, "tok")
|
||||
|
||||
assert.True(t, containsRel(feed, "self"))
|
||||
assert.True(t, containsRel(feed, "start"))
|
||||
assert.True(t, containsRel(feed, "first"))
|
||||
assert.True(t, containsRel(feed, "last"))
|
||||
assert.True(t, containsRel(feed, "previous"), "middle page must have previous")
|
||||
assert.True(t, containsRel(feed, "next"), "middle page must have next")
|
||||
|
||||
// self must point to the current page
|
||||
var selfHref string
|
||||
for _, l := range feed.Links {
|
||||
if l.Rel == "self" {
|
||||
selfHref = l.Href
|
||||
}
|
||||
}
|
||||
assert.Contains(t, selfHref, "page=2&per_page=50")
|
||||
assert.Contains(t, selfHref, "token=tok")
|
||||
|
||||
// next must advance the page
|
||||
var nextHref string
|
||||
for _, l := range feed.Links {
|
||||
if l.Rel == "next" {
|
||||
nextHref = l.Href
|
||||
}
|
||||
}
|
||||
assert.Contains(t, nextHref, "page=3")
|
||||
|
||||
// OpenSearch metadata
|
||||
require.NotNil(t, feed.TotalResults)
|
||||
assert.Equal(t, 1814, *feed.TotalResults)
|
||||
require.NotNil(t, feed.ItemsPerPage)
|
||||
assert.Equal(t, 50, *feed.ItemsPerPage)
|
||||
require.NotNil(t, feed.StartIndex)
|
||||
assert.Equal(t, 51, *feed.StartIndex, "startIndex should be 1-based offset of first item on page 2")
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_FirstPage_NoPrevious(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 1814, "")
|
||||
|
||||
rels := rels(feed)
|
||||
assert.NotContains(t, rels, "previous", "first page must not have previous")
|
||||
assert.Contains(t, rels, "next")
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_LastPage_NoNext(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 37, 50, 1814, "")
|
||||
|
||||
rels := rels(feed)
|
||||
assert.NotContains(t, rels, "next", "last page must not have next")
|
||||
assert.Contains(t, rels, "previous")
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_SinglePage(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 10, "")
|
||||
|
||||
rels := rels(feed)
|
||||
assert.NotContains(t, rels, "previous")
|
||||
assert.NotContains(t, rels, "next")
|
||||
// still emits self/start/first/last
|
||||
assert.Contains(t, rels, "self")
|
||||
assert.Contains(t, rels, "last")
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_EmptyCatalog(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 0, "")
|
||||
|
||||
rels := rels(feed)
|
||||
assert.NotContains(t, rels, "next")
|
||||
assert.NotContains(t, rels, "previous")
|
||||
assert.NotContains(t, rels, "last", "empty catalog should not advertise a last page")
|
||||
require.NotNil(t, feed.TotalResults)
|
||||
assert.Equal(t, 0, *feed.TotalResults)
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_TokenAppended(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 100, "abc")
|
||||
|
||||
xml, err := feed.GenerateXMLString()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.Count(xml, "token=abc") >= 3, "token should be appended to generated links")
|
||||
}
|
||||
Reference in New Issue
Block a user