Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 112 additions & 3 deletions internal/service/internal_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package service

import (
"net/url"
"os"
"path/filepath"
"testing"
Expand All @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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)
})
}
}
138 changes: 128 additions & 10 deletions internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"

Expand All @@ -39,6 +40,11 @@ const (
pathTypeDirOfFiles
)

const (
defaultPageSize = 50
maxPageSize = 200
)

const (
ignoreFile = true
includeFile = false
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, " ", " ")
Expand Down Expand Up @@ -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").
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions internal/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ 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
assert.Len(t, catalog.Entries, 3)
})

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
Expand All @@ -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)
})
Expand Down
Loading
Loading