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
+
+
+
+
+
+
+ {{if .EnableSearch}}
+
+
+
+ {{end}}
+
+
+
+
+ {{range .Entries}}
+ -
+ {{if .CoverURL}}
+
+ {{else}}
+
+ {{if eq .Type 0}}📄{{else}}📁{{end}}
+
+ {{end}}
+
+
+
+ {{if .Author}}By {{.Author}} | {{end}}
+ {{if .Size}}{{.SizeDisplay}} | {{end}}
+ Modified: {{.ModTimeDisplay}}
+
+
+
+ {{else}}
+ No entries found.
+ {{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,