diff --git a/internal/service/internal_test.go b/internal/service/internal_test.go index 479b7d5..2a889dc 100644 --- a/internal/service/internal_test.go +++ b/internal/service/internal_test.go @@ -1,6 +1,7 @@ package service import ( + "net/url" "os" "path/filepath" "testing" @@ -13,9 +14,9 @@ import ( func TestVerifyPath(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) - + trustedRoot := filepath.Join(wd, "testdata") - + tests := []struct { name string path string @@ -42,7 +43,7 @@ func TestVerifyPath(t *testing.T) { func TestInTrustedRoot(t *testing.T) { root := "/home/books" - + assert.True(t, inTrustedRoot("/home/books", root)) assert.True(t, inTrustedRoot("/home/books/folder", root)) assert.False(t, inTrustedRoot("/home/bookkeeping", root)) @@ -95,3 +96,111 @@ func TestExtractMetadata(t *testing.T) { assert.Equal(t, "F. Scott Fitzgerald", parsePdfValue(line, "/Author")) }) } + +func TestParsePage(t *testing.T) { + assert.Equal(t, 1, parsePage("")) + assert.Equal(t, 1, parsePage("invalid")) + assert.Equal(t, 1, parsePage("0")) + assert.Equal(t, 1, parsePage("-1")) + assert.Equal(t, 1, parsePage("1")) + assert.Equal(t, 5, parsePage("5")) + assert.Equal(t, 100, parsePage("100")) +} + +func TestPageSize(t *testing.T) { + s := OPDS{} + assert.Equal(t, defaultPageSize, s.pageSize()) + + s.PageSize = 10 + assert.Equal(t, 10, s.pageSize()) + + s.PageSize = 500 + assert.Equal(t, maxPageSize, s.pageSize()) + + s.PageSize = 0 + assert.Equal(t, defaultPageSize, s.pageSize()) +} + +func TestPagination(t *testing.T) { + s := OPDS{TrustedRoot: "testdata", HideCalibreFiles: true, HideDotFiles: true} + + t.Run("First page", func(t *testing.T) { + catalog, err := s.Scan("testdata/mybook", "/mybook", 1) + require.NoError(t, err) + assert.Equal(t, 1, catalog.Page) + assert.Equal(t, defaultPageSize, catalog.PageSize) + assert.Equal(t, 5, catalog.Total) + }) + + t.Run("Page with small page size", func(t *testing.T) { + s.PageSize = 2 + catalog, err := s.Scan("testdata/mybook", "/mybook", 1) + require.NoError(t, err) + assert.Equal(t, 1, catalog.Page) + assert.Equal(t, 2, catalog.PageSize) + assert.Equal(t, 5, catalog.Total) + assert.Len(t, catalog.Entries, 2) + }) + + t.Run("Second page", func(t *testing.T) { + s.PageSize = 2 + catalog, err := s.Scan("testdata/mybook", "/mybook", 2) + require.NoError(t, err) + assert.Equal(t, 2, catalog.Page) + assert.Equal(t, 5, catalog.Total) + assert.Len(t, catalog.Entries, 2) + }) + + t.Run("Last page with partial entries", func(t *testing.T) { + s.PageSize = 2 + catalog, err := s.Scan("testdata/mybook", "/mybook", 3) + require.NoError(t, err) + assert.Equal(t, 3, catalog.Page) + assert.Equal(t, 5, catalog.Total) + assert.Len(t, catalog.Entries, 1) + }) + + t.Run("Page beyond total", func(t *testing.T) { + s.PageSize = 2 + catalog, err := s.Scan("testdata/mybook", "/mybook", 100) + require.NoError(t, err) + assert.Equal(t, 100, catalog.Page) + assert.Empty(t, catalog.Entries) + }) +} + +func TestBuildPageURL(t *testing.T) { + tests := []struct { + name string + basePath string + query map[string]string + page int + want string + }{ + { + name: "simple path", + basePath: "/", + query: map[string]string{}, + page: 1, + want: "/?page=1", + }, + { + name: "path with existing query", + basePath: "/mybook", + query: map[string]string{"q": "test"}, + page: 2, + want: "/mybook?page=2&q=test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + values := make(url.Values) + for k, v := range tt.query { + values.Set(k, v) + } + result := buildPageURL(tt.basePath, values, tt.page) + assert.Equal(t, tt.want, result) + }) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index ca9a84b..acc01a5 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -17,6 +17,7 @@ import ( "path" "path/filepath" "sort" + "strconv" "strings" "time" @@ -39,6 +40,11 @@ const ( pathTypeDirOfFiles ) +const ( + defaultPageSize = 50 + maxPageSize = 200 +) + const ( ignoreFile = true includeFile = false @@ -58,14 +64,18 @@ type OPDS struct { EnableSearch bool ExtractMetadata bool BaseURL string + PageSize int // 0 means default (50) } type Catalog struct { - ID string - Title string - Type int - Entries []CatalogEntry - Cover string + ID string + Title string + Type int + Entries []CatalogEntry + Cover string + Total int // Total number of entries (before pagination) + Page int // Current page (1-indexed) + PageSize int // Number of entries per page } type CatalogEntry struct { @@ -90,7 +100,28 @@ const navigationType = "application/atom+xml;profile=opds-catalog;kind=navigatio var TimeNow = timeNowFunc() // Scan inspects the directory and builds a Catalog model -func (s OPDS) Scan(fPath string, urlPath string) (*Catalog, error) { +func (s OPDS) pageSize() int { + if s.PageSize <= 0 { + return defaultPageSize + } + if s.PageSize > maxPageSize { + return maxPageSize + } + return s.PageSize +} + +func parsePage(pageStr string) int { + if pageStr == "" { + return 1 + } + page := 1 + if n, err := strconv.Atoi(pageStr); err == nil && n > 0 { + page = n + } + return page +} + +func (s OPDS) Scan(fPath string, urlPath string, page int) (*Catalog, error) { dirEntries, err := os.ReadDir(fPath) if err != nil { return nil, err @@ -140,6 +171,26 @@ func (s OPDS) Scan(fPath string, urlPath string) (*Catalog, error) { s.sortEntries(catalog.Entries) + total := len(catalog.Entries) + pageSize := s.pageSize() + if page < 1 { + page = 1 + } + + start := (page - 1) * pageSize + end := start + pageSize + if start > total { + start = total + } + if end > total { + end = total + } + + catalog.Total = total + catalog.Page = page + catalog.PageSize = pageSize + catalog.Entries = catalog.Entries[start:end] + return catalog, nil } @@ -297,8 +348,6 @@ func (s OPDS) Handler(w http.ResponseWriter, req *http.Request) error { return nil } - slog.Debug("request", "urlPath", urlPath) - if _, err := os.Stat(fPath); err != nil { slog.Error("file system stat error", "error", err) w.WriteHeader(http.StatusNotFound) @@ -318,12 +367,21 @@ func (s OPDS) Handler(w http.ResponseWriter, req *http.Request) error { w.Header().Add("Expires", "0") } - catalog, err := s.Scan(fPath, urlPath) + page := parsePage(req.URL.Query().Get("page")) + catalog, err := s.Scan(fPath, urlPath, page) if err != nil { slog.Error("error scanning path", "error", err) return err } + slog.Debug("request", + "urlPath", urlPath, + "page", catalog.Page, + "pageSize", catalog.PageSize, + "total", catalog.Total, + "totalPages", (catalog.Total+catalog.PageSize-1)/catalog.PageSize, + ) + navFeed := s.makeFeed(catalog, req) var content []byte @@ -354,6 +412,9 @@ func (s OPDS) SearchHandler(w http.ResponseWriter, req *http.Request) error { return s.Handler(w, req) } + page := parsePage(req.URL.Query().Get("page")) + pageSize := s.pageSize() + catalog := &Catalog{ ID: "search:" + query, Title: "Search results for: " + query, @@ -387,6 +448,23 @@ func (s OPDS) SearchHandler(w http.ResponseWriter, req *http.Request) error { return err } + s.sortEntries(catalog.Entries) + + total := len(catalog.Entries) + start := (page - 1) * pageSize + end := start + pageSize + if start > total { + start = total + } + if end > total { + end = total + } + + catalog.Total = total + catalog.Page = page + catalog.PageSize = pageSize + catalog.Entries = catalog.Entries[start:end] + navFeed := s.makeFeed(catalog, req) acFeed := &opds.AcquisitionFeed{Feed: &navFeed, Dc: "http://purl.org/dc/terms/", Opds: "http://opds-spec.org/2010/catalog"} content, err := xml.MarshalIndent(acFeed, " ", " ") @@ -429,7 +507,6 @@ func (s OPDS) makeFeed(catalog *Catalog, req *http.Request) atom.Feed { Updated(TimeNow()). AddLink(opds.LinkBuilder.Rel("start").Href(s.joinURL("/")).Type(navigationType).Build()) - // Add search link if enabled if s.EnableSearch { feedBuilder = feedBuilder.AddLink(opds.LinkBuilder. Rel("search"). @@ -452,6 +529,42 @@ func (s OPDS) makeFeed(catalog *Catalog, req *http.Request) atom.Feed { Build()) } + if catalog.Total > catalog.PageSize { + totalPages := (catalog.Total + catalog.PageSize - 1) / catalog.PageSize + basePath := req.URL.Path + query := req.URL.Query() + + feedType := "application/atom+xml;profile=opds-catalog;kind=navigation" + if catalog.Type == pathTypeDirOfFiles { + feedType = "application/atom+xml;profile=opds-catalog;kind=acquisition" + } + + if catalog.Page > 1 { + feedBuilder = feedBuilder.AddLink(opds.LinkBuilder. + Rel("first"). + Href(s.joinURL(buildPageURL(basePath, query, 1))). + Type(feedType). + Build()) + feedBuilder = feedBuilder.AddLink(opds.LinkBuilder. + Rel("previous"). + Href(s.joinURL(buildPageURL(basePath, query, catalog.Page-1))). + Type(feedType). + Build()) + } + if catalog.Page < totalPages { + feedBuilder = feedBuilder.AddLink(opds.LinkBuilder. + Rel("next"). + Href(s.joinURL(buildPageURL(basePath, query, catalog.Page+1))). + Type(feedType). + Build()) + feedBuilder = feedBuilder.AddLink(opds.LinkBuilder. + Rel("last"). + Href(s.joinURL(buildPageURL(basePath, query, totalPages))). + Type(feedType). + Build()) + } + } + for _, entry := range catalog.Entries { title := entry.Name if entry.Title != "" { @@ -486,6 +599,11 @@ func (s OPDS) makeFeed(catalog *Catalog, req *http.Request) atom.Feed { return feedBuilder.Build() } +func buildPageURL(basePath string, query url.Values, page int) string { + query.Set("page", strconv.Itoa(page)) + return basePath + "?" + query.Encode() +} + func fileShouldBeIgnored(filename string, hideCalibreFiles, hideDotFiles bool) bool { // not ignore those directories if filename == currentDirectory || filename == parentDirectory { diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 84449b9..ba24795 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -72,7 +72,7 @@ func TestScan(t *testing.T) { s := service.OPDS{TrustedRoot: "testdata", HideCalibreFiles: true, HideDotFiles: true} t.Run("Scan root (dir of dirs)", func(t *testing.T) { - catalog, err := s.Scan("testdata", "/") + catalog, err := s.Scan("testdata", "/", 1) require.NoError(t, err) assert.Equal(t, "/", catalog.ID) // testdata has 3 folders: emptyFolder, mybook, new folder @@ -80,7 +80,7 @@ func TestScan(t *testing.T) { }) t.Run("Scan mybook (dir of files)", func(t *testing.T) { - catalog, err := s.Scan("testdata/mybook", "/mybook") + catalog, err := s.Scan("testdata/mybook", "/mybook", 1) require.NoError(t, err) assert.Equal(t, "/mybook", catalog.ID) // mybook has 6 files but mybook.opf should be ignored @@ -91,7 +91,7 @@ func TestScan(t *testing.T) { }) t.Run("Scan empty folder", func(t *testing.T) { - catalog, err := s.Scan("testdata/emptyFolder", "/emptyFolder") + catalog, err := s.Scan("testdata/emptyFolder", "/emptyFolder", 1) require.NoError(t, err) assert.Empty(t, catalog.Entries) }) diff --git a/main.go b/main.go index 1f49eb4..2170a6a 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ var ( extractMeta = flag.Bool("extract-metadata", false, "Extract metadata (title, author) from EPUB and PDF files.") baseURL = flag.String("url", "", "The base URL used for absolute links in the feed (e.g., https://opds.example.com).") logFormat = flag.String("log-format", "json", "Log format: json, text.") + pageSize = flag.Int("page-size", 50, "Number of entries per page (0 for default, max 200).") ) func main() { @@ -93,6 +94,7 @@ func main() { EnableSearch: *searchEnable, ExtractMetadata: *extractMeta, BaseURL: *baseURL, + PageSize: *pageSize, } http.HandleFunc("/", errorHandler(s.Handler))