From 13cc689bff00b72b4b8c69acbdf3f845b02e73b8 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 12:12:51 -0400 Subject: [PATCH] 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). --- internal/handlers/opds.go | 90 ++++++++++++++++++++----- internal/handlers/opds_test.go | 119 +++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 internal/handlers/opds_test.go diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index a847274..1d23b74 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -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) diff --git a/internal/handlers/opds_test.go b/internal/handlers/opds_test.go new file mode 100644 index 0000000..4fd3e30 --- /dev/null +++ b/internal/handlers/opds_test.go @@ -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") +}