diff --git a/CHANGELOG.md b/CHANGELOG.md index b23a141..fc74322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Browser detection and HTML view** — `-enable-html` flag to provide a modern, web-friendly interface when accessed via a web browser. - **No Pagination option** — `-no-pagination` flag to disable pagination and show all entries in a single feed. ## [1.8.0] - 2026-03-28 diff --git a/README.md b/README.md index 3fa717c..ea1b1f9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - **Flexible layout** — Organize by folders; optional metadata from EPUB/PDF - **Search** — Optional filename search (OpenSearch) - **Covers** — Optional `cover.jpg` / `folder.jpg` as catalog covers, or extract covers from EPUB files +- **Web-friendly** — Optional HTML interface for browsing your collection via a web browser - **Pagination** — Configurable page size for large catalogs - **Caching** — ETag/Last-Modified for conditional requests, gzip compression - **Health endpoint** — `/health` endpoint for monitoring and load balancers @@ -96,6 +97,7 @@ dir2opds -dir /path/to/books -port 8080 | `-debug` | Log requests | | `-dir` | Directory with books (default: `./books`) | | `-enable-cache` | Enable ETag/Last-Modified headers for conditional requests (bandwidth optimization) | +| `-enable-html` | Enable web-friendly HTML view for browsers | | `-extract-metadata` | Extract title/author from EPUB and PDF, and covers from EPUB | | `-gzip` | Enable gzip compression for responses (reduces bandwidth) | | `-hide-dot-files` | Hide files whose names start with a dot | @@ -117,13 +119,14 @@ dir2opds -dir /path/to/books -port 8080 For the best experience, use these flags: ```bash -dir2opds -dir /path/to/books -extract-metadata -enable-cache -gzip +dir2opds -dir /path/to/books -extract-metadata -enable-cache -gzip -enable-html ``` This enables: - **Metadata extraction** — Shows book titles and authors instead of filenames, plus cover thumbnails - **Caching** — Reduces bandwidth with ETag/Last-Modified headers - **Gzip compression** — Further reduces bandwidth for large catalogs +- **Web-friendly UI** — Provides a modern HTML interface when browsing via a web browser For public servers, also set the base URL: diff --git a/internal/service/html.go b/internal/service/html.go new file mode 100644 index 0000000..249c17d --- /dev/null +++ b/internal/service/html.go @@ -0,0 +1,337 @@ +package service + +import ( + "fmt" + "html/template" + "net/http" + "net/url" + "path" + "strings" +) + +const htmlTemplate = ` + + + + + + {{.Catalog.Title}} - dir2opds + + + +
+
+

dir2opds

+
+
+ +
+ {{if .EnableSearch}} + + {{end}} + + + + + + {{if gt .TotalPages 1}} + + {{end}} + + +
+ + +` + +type Breadcrumb struct { + Name string + Path string +} + +type HTMLEntry struct { + CatalogEntry + Href string + CoverURL string + SizeDisplay string + ModTimeDisplay string +} + +type HTMLData struct { + Catalog *Catalog + Entries []HTMLEntry + Breadcrumbs []Breadcrumb + EnableSearch bool + Query string + CurrentPage int + TotalPages int + PrevPageURL string + NextPageURL string +} + +func (s OPDS) renderHTML(w http.ResponseWriter, req *http.Request, catalog *Catalog) error { + tmpl, err := template.New("catalog").Parse(htmlTemplate) + if err != nil { + return err + } + + data := HTMLData{ + Catalog: catalog, + EnableSearch: s.EnableSearch, + Query: req.URL.Query().Get("q"), + CurrentPage: catalog.Page, + TotalPages: (catalog.Total + catalog.PageSize - 1) / catalog.PageSize, + } + + // Breadcrumbs + urlPath := strings.Trim(req.URL.Path, "/") + if urlPath != "" { + parts := strings.Split(urlPath, "/") + current := "" + for _, part := range parts { + current += "/" + part + data.Breadcrumbs = append(data.Breadcrumbs, Breadcrumb{ + Name: part, + Path: current, + }) + } + } + + // Entries + for _, entry := range catalog.Entries { + var entryPath string + if strings.HasPrefix(catalog.ID, "search:") { + entryPath = "/" + entry.Name + } else { + entryPath = path.Join(req.URL.Path, entry.Name) + } + + href := (&url.URL{Path: entryPath}).String() + + var coverURL string + if s.ExtractMetadata && entry.CoverPath != "" && entry.Type == pathTypeFile { + coverURL = "/cover?file=" + url.QueryEscape(entryPath) + } + + data.Entries = append(data.Entries, HTMLEntry{ + CatalogEntry: entry, + Href: href, + CoverURL: coverURL, + SizeDisplay: formatSize(entry.Size), + ModTimeDisplay: entry.ModTime.Format("2006-01-02"), + }) + } + + // Pagination + if data.CurrentPage > 1 { + data.PrevPageURL = buildPageURL(req.URL.Path, req.URL.Query(), data.CurrentPage-1) + } + if data.CurrentPage < data.TotalPages { + data.NextPageURL = buildPageURL(req.URL.Path, req.URL.Query(), data.CurrentPage+1) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + return tmpl.Execute(w, data) +} + +func formatSize(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/service/service.go b/internal/service/service.go index dd276c0..7e13839 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -68,6 +68,7 @@ type OPDS struct { MimeMap map[string]string EnableSearch bool ExtractMetadata bool + EnableHTML bool BaseURL string PageSize int NoPagination bool @@ -431,6 +432,11 @@ func (s OPDS) sortEntries(entries []CatalogEntry) { } } +func isBrowser(r *http.Request) bool { + accept := r.Header.Get("Accept") + return strings.Contains(accept, "text/html") +} + // Handler serves the content of a book file or // returns an Acquisition Feed when the entries are documents or // returns a Navigation Feed when the entries are other folders @@ -510,6 +516,10 @@ func (s OPDS) Handler(w http.ResponseWriter, req *http.Request) error { } } + if s.EnableHTML && isBrowser(req) { + return s.renderHTML(w, req, catalog) + } + navFeed := s.makeFeed(catalog, req) var content []byte @@ -593,6 +603,10 @@ func (s OPDS) SearchHandler(w http.ResponseWriter, req *http.Request) error { catalog.PageSize = pageSize catalog.Entries = catalog.Entries[start:end] + if s.EnableHTML && isBrowser(req) { + return s.renderHTML(w, req, catalog) + } + 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, " ", " ") diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 76d7ba3..0b1ac85 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -4,6 +4,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -30,6 +31,7 @@ func TestHandler(t *testing.T) { "servingAFile": {input: "/mybook/mybook.txt", want: "Fixture", WantedContentType: "text/plain; charset=utf-8", wantedStatusCode: 200}, "serving file with spaces": {input: "/mybook/mybook%20copy.txt", want: "Fixture", WantedContentType: "text/plain; charset=utf-8", wantedStatusCode: 200}, "http trasversal vulnerability check": {input: "/../../../../mybook", want: feed, WantedContentType: "application/atom+xml;profile=opds-catalog;kind=navigation", wantedStatusCode: 404}, + "browser request (HTML)": {input: "/", want: "dir2opds", WantedContentType: "text/html; charset=utf-8", wantedStatusCode: 200}, } for name, tc := range tests { @@ -40,9 +42,13 @@ func TestHandler(t *testing.T) { HideCalibreFiles: true, HideDotFiles: true, NoCache: true, + EnableHTML: strings.Contains(name, "browser request"), } w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, tc.input, nil) + if strings.Contains(name, "browser request") { + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + } service.TimeNow = func() time.Time { return time.Date(2020, 05, 25, 00, 00, 00, 0, time.UTC) } @@ -62,7 +68,11 @@ func TestHandler(t *testing.T) { return } assert.Equal(t, tc.WantedContentType, resp.Header.Get("Content-Type")) - assert.Equal(t, tc.want, string(body)) + if name == "browser request (HTML)" { + assert.Contains(t, string(body), tc.want) + } else { + assert.Equal(t, tc.want, string(body)) + } }) } @@ -131,6 +141,24 @@ func TestBaseURL(t *testing.T) { assert.Contains(t, string(body), `href="https://opds.example.com/mybook/mybook.epub"`) }) + t.Run("Search browser support", func(t *testing.T) { + s.EnableSearch = true + s.EnableHTML = true + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/search?q=mybook", nil) + req.Header.Set("Accept", "text/html") + + err := s.SearchHandler(w, req) + require.NoError(t, err) + + resp := w.Result() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.Contains(t, string(body), "Search results for: mybook") + }) + t.Run("OpenSearch with BaseURL", func(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/opensearch.xml", nil) diff --git a/main.go b/main.go index 0c5daf9..d676097 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ var ( mimeMapStr = flag.String("mime-map", "", "Custom mime types (e.g., '.mobi:application/x-mobipocket-ebook,.azw3:application/vnd.amazon.ebook')") searchEnable = flag.Bool("search", false, "Enable basic filename search.") extractMeta = flag.Bool("extract-metadata", false, "Extract metadata (title, author, cover) from EPUB and PDF files.") + enableHTML = flag.Bool("enable-html", false, "Enable web-friendly HTML view for browsers.") 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).") @@ -97,6 +98,7 @@ func main() { MimeMap: parseMimeMap(*mimeMapStr), EnableSearch: *searchEnable, ExtractMetadata: *extractMeta, + EnableHTML: *enableHTML, BaseURL: *baseURL, PageSize: *pageSize, NoPagination: *noPagination,