diff --git a/cmd/server/tests/opds_test.go b/cmd/server/tests/opds_test.go index c6e9cb3..bba8933 100644 --- a/cmd/server/tests/opds_test.go +++ b/cmd/server/tests/opds_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -435,3 +436,115 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) { 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") + }) +} diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index 9b5d34b..a417f38 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -90,6 +90,9 @@ func appendToken(url, token string) string { // catalogMediaType is the OPDS media type for an acquisition catalog feed. 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, // previous, next, last) and OpenSearch paging metadata (totalResults, // 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 { + 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") page := c.QueryParam("page") @@ -216,8 +320,6 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { // Get user's visible libraries userID := device.UserID.Bytes - userUUID := uuid.UUID(userID) - 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")) @@ -276,7 +378,23 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { feed.AddLink(searchURL, "application/opensearchdescription+xml", "search") // 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() title := item.Title author := "" @@ -323,7 +441,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { } // 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 { collectionScheme := fmt.Sprintf("%s/collections", baseURL) for _, col := range collections { @@ -335,6 +453,125 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { 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 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")) } - 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) } @@ -377,20 +614,50 @@ 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}) + 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")) } + // 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) // 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 == "" { - 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( - "Bookhoard", - "Search the Bookhoard library", + shortName, + description, searchURL, ) xmlString, err := desc.GenerateXMLString() @@ -404,6 +671,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error { // Search media items allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: scopedLibraryID, SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true}, Offset: pgtype.Int4{Int32: 0, 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) } + // 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) entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype)) diff --git a/internal/router/opds.go b/internal/router/opds.go index 32423ed..973a0a1 100644 --- a/internal/router/opds.go +++ b/internal/router/opds.go @@ -19,6 +19,7 @@ func registerOPDSRoutes(cfg *Config) { opds := e.Group("/opds/devices") opds.Use(cfg.DeviceAuthMiddleware.Authenticate) 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/nav", cfg.OPDSHandler.GetDeviceNavigation) opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)