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:
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user