From 01c59b47328cc1551f13ec0295dc940de1888abd Mon Sep 17 00:00:00 2001 From: Sinuhe Tellez Date: Sat, 11 Jul 2026 18:29:59 -0400 Subject: [PATCH] feat: extract EPUB series metadata for OPDS feeds Extract calibre:series and calibre:series_index from EPUB OPF tags and emit them as and in acquisition feeds. Enables reading order views in OPDS clients that support series metadata. Changes: - Added Series and SeriesIndex fields to CatalogEntry - Extended extractEpubMetadata() to parse tags with namespace fallback - Extended Entry struct with DcSeries and DcSeriesPosition XML fields - Added Series() and SeriesPosition() builder methods - Updated makeFeed() to emit series metadata when ExtractMetadata is on - Updated extractMetadata() return signature to 6 values --- internal/service/internal_test.go | 4 +-- internal/service/service.go | 58 +++++++++++++++++++++++++------ opds/atom.go | 18 +++++----- opds/entry_builder.go | 8 +++++ 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/internal/service/internal_test.go b/internal/service/internal_test.go index b5ca259..b7a5e11 100644 --- a/internal/service/internal_test.go +++ b/internal/service/internal_test.go @@ -86,8 +86,8 @@ func TestSortEntries(t *testing.T) { func TestExtractMetadata(t *testing.T) { t.Run("Extract EPUB", func(t *testing.T) { path := filepath.Join("testdata", "mybook", "mybook.epub") - title, author, coverPath, description := extractEpubMetadata(path) - t.Logf("EPUB Title: %q, Author: %q, CoverPath: %q, Description: %q", title, author, coverPath, description) + title, author, coverPath, description, series, seriesIndex := extractEpubMetadata(path) + t.Logf("EPUB Title: %q, Author: %q, CoverPath: %q, Description: %q, Series: %q, SeriesIndex: %q", title, author, coverPath, description, series, seriesIndex) }) t.Run("Parse PDF value", func(t *testing.T) { diff --git a/internal/service/service.go b/internal/service/service.go index c45e1b7..a7d5065 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -94,6 +94,8 @@ type CatalogEntry struct { Author string CoverPath string Description string + Series string + SeriesIndex string } type IsDirer interface { @@ -204,7 +206,7 @@ func (s OPDS) Scan(fPath string, urlPath string, page int) (*Catalog, error) { if s.ExtractMetadata && !entry.IsDir() { idx := len(catalog.Entries) - 1 - title, author, coverPath, description := extractMetadata(entryPath) + title, author, coverPath, description, series, seriesIndex := extractMetadata(entryPath) if title != "" { catalog.Entries[idx].Title = title } @@ -217,6 +219,12 @@ func (s OPDS) Scan(fPath string, urlPath string, page int) (*Catalog, error) { if description != "" { catalog.Entries[idx].Description = description } + if series != "" { + catalog.Entries[idx].Series = series + } + if seriesIndex != "" { + catalog.Entries[idx].SeriesIndex = seriesIndex + } } } @@ -253,22 +261,22 @@ func (s OPDS) Scan(fPath string, urlPath string, page int) (*Catalog, error) { return catalog, nil } -func extractMetadata(path string) (string, string, string, string) { +func extractMetadata(path string) (string, string, string, string, string, string) { ext := strings.ToLower(filepath.Ext(path)) switch ext { case ".epub": return extractEpubMetadata(path) case ".pdf": title, author := extractPdfMetadata(path) - return title, author, "", "" + return title, author, "", "", "", "" } - return "", "", "", "" + return "", "", "", "", "", "" } -func extractEpubMetadata(path string) (string, string, string, string) { +func extractEpubMetadata(path string) (string, string, string, string, string, string) { r, err := zip.OpenReader(path) if err != nil { - return "", "", "", "" + return "", "", "", "", "", "" } defer r.Close() @@ -281,18 +289,18 @@ func extractEpubMetadata(path string) (string, string, string, string) { } if opfPath == "" { - return "", "", "", "" + return "", "", "", "", "", "" } f, err := r.Open(opfPath) if err != nil { - return "", "", "", "" + return "", "", "", "", "", "" } defer f.Close() opfContent, err := io.ReadAll(f) if err != nil { - return "", "", "", "" + return "", "", "", "", "", "" } var opf struct { @@ -300,6 +308,10 @@ func extractEpubMetadata(path string) (string, string, string, string) { Title string `xml:"title"` Creator string `xml:"creator"` Description string `xml:"description"` + Meta []struct { + Name string `xml:"name,attr"` + Content string `xml:"content,attr"` + } `xml:"meta"` } `xml:"metadata"` Manifest struct { Items []struct { @@ -312,7 +324,7 @@ func extractEpubMetadata(path string) (string, string, string, string) { decoder := xml.NewDecoder(bytes.NewReader(opfContent)) if err := decoder.Decode(&opf); err != nil { - return "", "", "", "" + return "", "", "", "", "", "" } // If standard unmarshal fails to get values due to namespaces @@ -324,6 +336,10 @@ func extractEpubMetadata(path string) (string, string, string, string) { Title string `xml:"title"` Creator string `xml:"creator"` Description string `xml:"description"` + Meta []struct { + Name string `xml:"name,attr"` + Content string `xml:"content,attr"` + } `xml:"meta"` } `xml:"metadata"` } _ = decoder.Decode(&opf2) @@ -336,12 +352,25 @@ func extractEpubMetadata(path string) (string, string, string, string) { if opf2.Metadata.Description != "" { opf.Metadata.Description = opf2.Metadata.Description } + if len(opf2.Metadata.Meta) > 0 && len(opf.Metadata.Meta) == 0 { + opf.Metadata.Meta = opf2.Metadata.Meta + } + } + + var series, seriesIndex string + for _, meta := range opf.Metadata.Meta { + switch meta.Name { + case "calibre:series": + series = meta.Content + case "calibre:series_index": + seriesIndex = meta.Content + } } // Find cover image in manifest coverPath := findEpubCover(r, opf.Manifest.Items, opfPath) - return opf.Metadata.Title, opf.Metadata.Creator, coverPath, opf.Metadata.Description + return opf.Metadata.Title, opf.Metadata.Creator, coverPath, opf.Metadata.Description, series, seriesIndex } func findEpubCover(r *zip.ReadCloser, items []struct { @@ -953,6 +982,13 @@ func (s OPDS) makeFeed(catalog *Catalog, req *http.Request) opds.Feed { entryBuilder = entryBuilder.Summary(&text) } + if s.ExtractMetadata && entry.Series != "" { + entryBuilder = entryBuilder.Series(entry.Series) + } + if s.ExtractMetadata && entry.SeriesIndex != "" { + entryBuilder = entryBuilder.SeriesPosition(entry.SeriesIndex) + } + if s.ExtractMetadata && entry.CoverPath != "" && entry.Type == pathTypeFile { coverURL := s.joinURL("/cover?file=" + url.QueryEscape(entryPath)) ext := strings.ToLower(filepath.Ext(entry.CoverPath)) diff --git a/opds/atom.go b/opds/atom.go index 1aded1e..1594ce1 100644 --- a/opds/atom.go +++ b/opds/atom.go @@ -27,14 +27,16 @@ type Feed struct { // Entry is an Atom entry. type Entry struct { - Title string `xml:"title"` - ID string `xml:"id"` - Link []Link `xml:"link"` - Published TimeStr `xml:"published"` - Updated TimeStr `xml:"updated"` - Author *Person `xml:"author"` - Summary *Text `xml:"summary"` - Content *Text `xml:"content"` + Title string `xml:"title"` + ID string `xml:"id"` + Link []Link `xml:"link"` + Published TimeStr `xml:"published"` + Updated TimeStr `xml:"updated"` + Author *Person `xml:"author"` + Summary *Text `xml:"summary"` + Content *Text `xml:"content"` + DcSeries string `xml:"dc:series,omitempty"` + DcSeriesPosition string `xml:"dc:seriesPosition,omitempty"` } // Link is an Atom link with optional OPDS 1.2 facet attributes. diff --git a/opds/entry_builder.go b/opds/entry_builder.go index 400f756..0b72739 100644 --- a/opds/entry_builder.go +++ b/opds/entry_builder.go @@ -40,6 +40,14 @@ func (e entryBuilder) Content(content *Text) entryBuilder { return builder.Set(e, "Content", content).(entryBuilder) } +func (e entryBuilder) Series(series string) entryBuilder { + return builder.Set(e, "DcSeries", series).(entryBuilder) +} + +func (e entryBuilder) SeriesPosition(pos string) entryBuilder { + return builder.Set(e, "DcSeriesPosition", pos).(entryBuilder) +} + func (e entryBuilder) Build() Entry { return builder.GetStruct(e).(Entry) }