feat(opds): library folder navigation and library-scoped search

The device catalog flattened every visible library into one list, so
duplicate copies of the same book each got an entry and search had no
library context. The root feed is now a standard OPDS 1.1 navigation feed
that mirrors the web UI's library model:

- Root /catalog: an "All Books" entry first (the previous flat cross-
  library behavior, also still served at ?all=1 for clients that want
  the single flat list), followed by one folder entry per visible
  library with live book counts from GetVisibleLibraryMediaCounts.
- New GET /library/:libraryId/catalog: acquisition feed scoped to one
  visible library (403 when the device owner cannot see it), paginated,
  with an up-link back to the root.
- Scoped search: each library feed's rel="search" OpenSearch template
  pins &library_id=<id>, so opening search from inside a library folder
  searches only that library — clients substitute only {searchTerms},
  so no client-side changes are required. Root search stays global.
- Global search entries now carry the owning library as a category (and
  as a fallback summary when the book has no description), so duplicate
  copies are distinguishable in unscoped result lists.

The acquisition entry builder is extracted into addAcquisitionEntries and
shared by the all-books and per-library feeds. bookhoard.koplugin needs
no changes: it only registers the root URL, and KOReader's stock OPDS
client renders navigation feeds natively.

Tests: TestOPDSLibraryFolders covers the nav-feed shape, flat ?all=1
mode, scoped catalog isolation, scoped/global search behavior, and 403s
for libraries outside the device's visibility. Library names avoid the
word "test" on purpose — setupDeviceTest re-runs setupTestServer's
setup-time cleanup, which deletes every library whose name contains it.
This commit is contained in:
John O'Keefe
2026-09-21 18:09:30 -04:00
parent 45e3a256e2
commit 5da97b2e5d
3 changed files with 402 additions and 11 deletions
+113
View File
@@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -435,3 +436,115 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode) t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode)
} }
} }
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
// catalog is a navigation feed with an "All Books" entry plus one folder per
// visible library, the scoped catalog only lists that library's books, and
// the library-scoped search parameter restricts results to that library.
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
// catalog is a navigation feed with an "All Books" entry plus one folder per
// visible library, the scoped catalog only lists that library's books, and
// the library-scoped search parameter restricts results to that library.
// NOTE: library names deliberately avoid the word "test" — setupTestServer's
// setup-time cleanup deletes every library whose name contains it (and
// setupDeviceTest re-runs that cleanup mid-test).
func TestOPDSLibraryFolders(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
// Two libraries, one distinct book each.
lib1Resp := createLibrary(t, client, setup, "OPDS Folder Lib Alpha")
lib2Resp := createLibrary(t, client, setup, "OPDS Folder Lib Beta")
lib1ID := lib1Resp["id"].(string)
lib2ID := lib2Resp["id"].(string)
// Real directories: the folder API validates that the path exists on the
// host running the server (container path /app/uploads won't exist when
// tests run on the host).
addFolderToLibrary(t, setup, lib1ID, t.TempDir())
addFolderToLibrary(t, setup, lib2ID, t.TempDir())
createTestMediaItemIDInLibrary(t, client, setup, lib1ID, "OPDS Folder Book 1")
createTestMediaItemIDInLibrary(t, client, setup, lib2ID, "OPDS Folder Book 2")
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "OPDS Folders Device", "koreader", "opds-folders-"+uuid.NewString())
base := setup.Server.URL + "/opds/devices/" + device.ID.String()
// Explicit cleanup: these names don't contain "test", so the harness
// cleanup skips them; remove them ourselves. Registered as a defer AFTER
// the setup.Close defer so LIFO ordering runs it while the pool is open.
defer deleteLibraries(t, setup,
pgtype.UUID{Bytes: uuid.MustParse(lib1ID), Valid: true},
pgtype.UUID{Bytes: uuid.MustParse(lib2ID), Valid: true})
getFeed := func(t *testing.T, url string) (int, string, string) {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
contentType := resp.Header.Get("Content-Type")
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, contentType, string(bodyBytes)
}
t.Run("root catalog is a navigation feed with All Books and library folders", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/catalog")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=navigation")
assert.Contains(t, body, "All Books")
assert.Contains(t, body, "OPDS Folder Lib Alpha")
assert.Contains(t, body, "OPDS Folder Lib Beta")
assert.Contains(t, body, "/library/"+lib1ID+"/catalog")
assert.Contains(t, body, "/library/"+lib2ID+"/catalog")
// The nav feed itself must not inline book entries.
assert.NotContains(t, body, "OPDS Folder Book 1")
})
t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/catalog?all=1")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=acquisition")
assert.Contains(t, body, "OPDS Folder Book 1")
assert.Contains(t, body, "OPDS Folder Book 2")
})
t.Run("scoped catalog lists only its own library's books", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/library/"+lib1ID+"/catalog")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=acquisition")
assert.Contains(t, body, "OPDS Folder Book 1")
assert.NotContains(t, body, "OPDS Folder Book 2")
// Scoped search discovery: template pins the library.
assert.Contains(t, body, "library_id="+lib1ID)
})
t.Run("scoped catalog rejects libraries outside device visibility", func(t *testing.T) {
status, _, _ := getFeed(t, base+"/library/11111111-2222-3333-4444-555555555555/catalog")
assert.Equal(t, http.StatusForbidden, status)
})
t.Run("scoped search restricts results to the library", func(t *testing.T) {
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder&library_id="+lib2ID)
require.Equal(t, http.StatusOK, status)
assert.Contains(t, body, "OPDS Folder Book 2")
assert.NotContains(t, body, "OPDS Folder Book 1")
})
t.Run("scoped search rejects inaccessible library", func(t *testing.T) {
status, _, _ := getFeed(t, base+"/search?q=OPDS&library_id=11111111-2222-3333-4444-555555555555")
assert.Equal(t, http.StatusForbidden, status)
})
t.Run("global search labels entries with their library", func(t *testing.T) {
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, body, "OPDS Folder Book 1")
assert.Contains(t, body, "OPDS Folder Book 2")
assert.Contains(t, body, "OPDS Folder Lib Alpha")
assert.Contains(t, body, "OPDS Folder Lib Beta")
})
}
+288 -11
View File
@@ -90,6 +90,9 @@ func appendToken(url, token string) string {
// catalogMediaType is the OPDS media type for an acquisition catalog feed. // catalogMediaType is the OPDS media type for an acquisition catalog feed.
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition" const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
// navFeedMediaType is the OPDS media type for a navigation catalog feed.
const navFeedMediaType = "application/atom+xml;profile=opds-catalog;kind=navigation"
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first, // addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
// previous, next, last) and OpenSearch paging metadata (totalResults, // previous, next, last) and OpenSearch paging metadata (totalResults,
// itemsPerPage, startIndex) to a feed based on the current page position. // itemsPerPage, startIndex) to a feed based on the current page position.
@@ -172,8 +175,109 @@ func formatLabelFromPath(path string) string {
} }
} }
// GetDeviceCatalog returns the OPDS catalog feed for a device // GetDeviceCatalog serves the OPDS root feed for a device.
//
// By default it returns a navigation feed: an "All Books" entry (the flat
// cross-library catalog) followed by one folder entry per visible library.
// OPDS clients render navigation entries as folders, so each library's
// books — and its library-scoped search — live under their own node instead
// of interleaving duplicate copies of the same book in one flat list.
// Passing ?all=1 serves the flat cross-library acquisition feed directly,
// preserving the pre-folders behavior.
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
if c.QueryParam("all") == "1" {
return h.serveAllBooksCatalog(c)
}
deviceID := c.Param("deviceId")
// Get base URLs
_, opdsBaseURL, err := h.getBaseURLs(c)
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
}
// Parse device ID
deviceUUID, err := uuid.Parse(deviceID)
if err != nil {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
}
// Verify device exists
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
if err != nil {
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
}
// Get user's visible libraries
userID := device.UserID.Bytes
libraries, 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)
feed := opds.NewFeed(
fmt.Sprintf("urn:uuid:%s", deviceID),
"Bookhoard Library",
)
// OpenSearch discovery: the root search stays global (all libraries).
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
updated := time.Now().UTC().Format("2006-01-02T15:04:05Z")
// "All Books" first: the flat cross-library catalog (?all=1).
allBooks := opds.NewEntry(
fmt.Sprintf("urn:uuid:%s-all-books", deviceID),
"All Books",
"",
updated,
)
allBooks.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/catalog?all=1", opdsBaseURL, deviceID), token), catalogMediaType, "subsection")
allBooks.SetSummary("Browse every book across all your libraries")
feed.AddEntry(allBooks)
// One folder per visible library, with live book counts.
counts := make(map[pgtype.UUID]int64)
if countRows, cerr := h.db.GetVisibleLibraryMediaCounts(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}); cerr == nil {
for _, row := range countRows {
counts[row.ID] = row.MediaCount
}
}
for _, lib := range libraries {
libUUID := uuid.UUID(lib.ID.Bytes).String()
title := lib.Name
if n, ok := counts[lib.ID]; ok {
title = fmt.Sprintf("%s (%d)", lib.Name, n)
}
entry := opds.NewEntry(
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
title,
"",
updated,
)
entry.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID), token), catalogMediaType, "subsection")
entry.SetSummary("Browse books in the " + lib.Name + " library")
feed.AddEntry(entry)
}
// Generate XML
xmlString, err := feed.GenerateXMLString()
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
}
c.Response().Header().Set("Content-Type", navFeedMediaType)
return c.String(http.StatusOK, xmlString)
}
// serveAllBooksCatalog is the original flat cross-library acquisition feed:
// every book from every visible library, paginated as one list.
func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId") deviceID := c.Param("deviceId")
page := c.QueryParam("page") page := c.QueryParam("page")
@@ -216,8 +320,6 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
userUUID := uuid.UUID(userID)
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
@@ -276,7 +378,23 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search") feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
// Add entries // Add entries
for _, item := range allItems { h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, allItems, baseURL, opdsBaseURL, token)
// Generate XML
xmlString, err := feed.GenerateXMLString()
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
}
c.Response().Header().Set("Content-Type", catalogMediaType)
return c.String(http.StatusOK, xmlString)
}
// addAcquisitionEntries fills an acquisition feed with book entries for the
// given items. Shared body of the all-books and per-library catalogs.
func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed, device database.Devices, deviceID string, items []database.ListMediaItemsByLibraryRow, baseURL, opdsBaseURL, token string) {
userUUID := uuid.UUID(device.UserID.Bytes)
for _, item := range items {
bookUUID := uuid.UUID(item.ID.Bytes).String() bookUUID := uuid.UUID(item.ID.Bytes).String()
title := item.Title title := item.Title
author := "" author := ""
@@ -323,7 +441,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
} }
// Add collections as categories // Add collections as categories
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}) collections, err := h.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
if err == nil { if err == nil {
collectionScheme := fmt.Sprintf("%s/collections", baseURL) collectionScheme := fmt.Sprintf("%s/collections", baseURL)
for _, col := range collections { for _, col := range collections {
@@ -335,6 +453,125 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
feed.AddEntry(entry) feed.AddEntry(entry)
} }
}
// GetLibraryCatalog serves an acquisition feed scoped to one of the device
// owner's visible libraries. Its search link is library-scoped: OPDS clients
// only substitute {searchTerms}, so opening search from inside this folder
// searches within this library only.
func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId")
libraryID := c.Param("libraryId")
page := c.QueryParam("page")
perPage := c.QueryParam("per_page")
includeFormat := c.QueryParam("include_format")
// Parse pagination parameters
pageNum := 1
if page != "" {
if num, err := strconv.Atoi(page); err == nil && num > 0 {
pageNum = num
}
}
perPageNum := h.opdsDefaultPageSize()
maxPerPage := h.opdsMaxPageSize()
if perPage != "" {
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
perPageNum = num
}
}
// Get base URLs
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
}
// Parse IDs
deviceUUID, err := uuid.Parse(deviceID)
if err != nil {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
}
// Verify device exists
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
if err != nil {
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
}
// The library must be visible to the device owner.
userID := device.UserID.Bytes
libraries, 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"))
}
var library *database.GetUserVisibleLibrariesRow
for i := range libraries {
if libraries[i].ID.Bytes == libUUID {
library = &libraries[i]
break
}
}
if library == nil {
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
}
// Get media items
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), library.ID)
if err != nil {
items = []database.ListMediaItemsByLibraryRow{}
}
// Filter by format if specified
if includeFormat != "" {
filteredItems := []database.ListMediaItemsByLibraryRow{}
for _, item := range items {
if strings.EqualFold(item.FormatGroup, includeFormat) {
filteredItems = append(filteredItems, item)
}
}
items = filteredItems
}
// Pagination
totalItems := len(items)
startIdx := (pageNum - 1) * perPageNum
endIdx := startIdx + perPageNum
if startIdx >= totalItems {
items = []database.ListMediaItemsByLibraryRow{}
} else if endIdx > totalItems {
items = items[startIdx:]
} else {
items = items[startIdx:endIdx]
}
// Create OPDS feed
feed := opds.NewFeed(
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
library.Name,
)
token := h.getAuthToken(c)
catalogBase := fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID)
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
// Library-scoped search discovery: the template pins this library.
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}&library_id=%s", opdsBaseURL, deviceID, libUUID), token)
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
// Up-link back to the root navigation feed.
rootURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
feed.AddLink(rootURL, navFeedMediaType, "up")
// Add entries
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, items, baseURL, opdsBaseURL, token)
// Generate XML // Generate XML
xmlString, err := feed.GenerateXMLString() xmlString, err := feed.GenerateXMLString()
@@ -342,7 +579,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
} }
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition") c.Response().Header().Set("Content-Type", catalogMediaType)
return c.String(http.StatusOK, xmlString) return c.String(http.StatusOK, xmlString)
} }
@@ -377,20 +614,50 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
} }
// Optional library scope: library folder feeds advertise their own search
// link with this parameter, so opening search from inside a library
// folder searches within that library only.
var scopedLibraryID pgtype.UUID
scopedLibraryName := ""
if libParam := c.QueryParam("library_id"); libParam != "" {
parsed, perr := uuid.Parse(libParam)
if perr != nil {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
}
for i := range libraries {
if libraries[i].ID.Bytes == parsed {
scopedLibraryID = libraries[i].ID
scopedLibraryName = libraries[i].Name
break
}
}
if !scopedLibraryID.Valid {
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
}
}
token := h.getAuthToken(c) token := h.getAuthToken(c)
// No query: serve the OpenSearch description document so clients can learn // No query: serve the OpenSearch description document so clients can learn
// the search template (contains the {searchTerms} placeholder). // the search URL template (contains the {searchTerms} placeholder).
if query == "" { if query == "" {
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token) template := fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID)
shortName := "Bookhoard"
description := "Search the Bookhoard library"
if scopedLibraryID.Valid {
template += "&library_id=" + scopedLibraryID.String()
shortName = "Bookhoard: " + scopedLibraryName
description = "Search the " + scopedLibraryName + " library"
}
searchURL := appendToken(template, token)
desc := opds.NewSearchDescription( desc := opds.NewSearchDescription(
"Bookhoard", shortName,
"Search the Bookhoard library", description,
searchURL, searchURL,
) )
xmlString, err := desc.GenerateXMLString() xmlString, err := desc.GenerateXMLString()
@@ -404,6 +671,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Search media items // Search media items
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true}, UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: scopedLibraryID,
SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true}, SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true},
Offset: pgtype.Int4{Int32: 0, Valid: true}, Offset: pgtype.Int4{Int32: 0, Valid: true},
Limit: pgtype.Int4{Int32: 1000, Valid: true}, Limit: pgtype.Int4{Int32: 1000, Valid: true},
@@ -451,6 +719,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
entry.SetSummary(item.Description.String) entry.SetSummary(item.Description.String)
} }
// Global (unscoped) search: label which library each copy lives in so
// duplicate copies of the same book are distinguishable.
if !scopedLibraryID.Valid && item.LibraryName != "" {
entry.AddCategory(baseURL+"/libraries", item.LibraryName)
if !item.Description.Valid || item.Description.String == "" {
entry.SetSummary("Library: " + item.LibraryName)
}
}
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token) downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype)) entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
+1
View File
@@ -19,6 +19,7 @@ func registerOPDSRoutes(cfg *Config) {
opds := e.Group("/opds/devices") opds := e.Group("/opds/devices")
opds.Use(cfg.DeviceAuthMiddleware.Authenticate) opds.Use(cfg.DeviceAuthMiddleware.Authenticate)
opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog) opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog)
opds.GET("/:deviceId/library/:libraryId/catalog", cfg.OPDSHandler.GetLibraryCatalog)
opds.GET("/:deviceId/search", cfg.OPDSHandler.SearchDeviceCatalog) opds.GET("/:deviceId/search", cfg.OPDSHandler.SearchDeviceCatalog)
opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation) opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation)
opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook) opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)