diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py
index ff425ef..a83a107 100644
--- a/abogen/integrations/calibre_opds.py
+++ b/abogen/integrations/calibre_opds.py
@@ -209,13 +209,15 @@ class CalibreOPDSClient:
last_error: Optional[Exception] = None
for path, params in candidates:
try:
- return self.fetch_feed(path, params=params)
+ feed = self.fetch_feed(path, params=params)
+ return self._filter_feed_entries(feed, cleaned)
except CalibreOPDSError as exc:
last_error = exc
continue
if last_error is not None:
raise last_error
- return self.fetch_feed()
+ feed = self.fetch_feed()
+ return self._filter_feed_entries(feed, cleaned)
def download(self, href: str) -> DownloadedResource:
if not href:
@@ -274,11 +276,13 @@ class CalibreOPDSClient:
feed_title = root.findtext("atom:title", default=None, namespaces=NS)
links = self._parse_links(root.findall("atom:link", NS), base_url)
parsed_entries = [self._parse_entry(node, base_url) for node in root.findall("atom:entry", NS)]
- entries = [
- entry
- for entry in parsed_entries
- if entry.download and self._is_supported_download(entry.download)
- ]
+ entries: List[OPDSEntry] = []
+ for entry in parsed_entries:
+ if entry.download and self._is_supported_download(entry.download):
+ entries.append(entry)
+ continue
+ if self._has_navigation_link(entry):
+ entries.append(entry)
return OPDSFeed(id=feed_id, title=feed_title, entries=entries, links=links)
def _parse_entry(self, node: ET.Element, base_url: str) -> OPDSEntry:
@@ -620,6 +624,58 @@ class CalibreOPDSClient:
# No valid acquisition-style link exposed
return None
+ @staticmethod
+ def _has_navigation_link(entry: OPDSEntry) -> bool:
+ for link in entry.links:
+ href = (link.href or "").strip()
+ if not href:
+ continue
+ rel = (link.rel or "").strip().lower()
+ link_type = (link.type or "").strip().lower()
+ if "acquisition" in rel:
+ continue
+ if rel == "self":
+ continue
+ if "opds-catalog" in link_type:
+ return True
+ if rel.endswith("navigation") or rel.endswith("collection"):
+ return True
+ if rel.startswith("http://opds-spec.org/sort") or rel.startswith("http://opds-spec.org/group"):
+ return True
+ return False
+
+ @staticmethod
+ def _entry_matches_query(entry: OPDSEntry, tokens: List[str]) -> bool:
+ if not tokens:
+ return True
+ haystack_parts: List[str] = []
+ if entry.title:
+ haystack_parts.append(entry.title)
+ if entry.authors:
+ haystack_parts.extend(entry.authors)
+ if entry.series:
+ haystack_parts.append(entry.series)
+ if entry.summary:
+ haystack_parts.append(entry.summary)
+ if entry.tags:
+ haystack_parts.extend(entry.tags)
+ if entry.alternate and entry.alternate.title:
+ haystack_parts.append(entry.alternate.title)
+ combined = " ".join(part for part in haystack_parts if part).lower()
+ if not combined:
+ return False
+ return all(token in combined for token in tokens)
+
+ def _filter_feed_entries(self, feed: OPDSFeed, query: str) -> OPDSFeed:
+ normalized = (query or "").strip().lower()
+ if not normalized:
+ return feed
+ tokens = [token for token in re.split(r"\s+", normalized) if token]
+ if not tokens:
+ return feed
+ filtered = [entry for entry in feed.entries if self._entry_matches_query(entry, tokens)]
+ return dataclasses.replace(feed, entries=filtered)
+
def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
"""Helper used by APIs to convert a feed into JSON-serialisable payloads."""
diff --git a/tests/test_calibre_opds.py b/tests/test_calibre_opds.py
index 22d4186..30a3c9f 100644
--- a/tests/test_calibre_opds.py
+++ b/tests/test_calibre_opds.py
@@ -1,4 +1,9 @@
-from abogen.integrations.calibre_opds import CalibreOPDSClient, feed_to_dict
+from abogen.integrations.calibre_opds import (
+ CalibreOPDSClient,
+ OPDSEntry,
+ OPDSFeed,
+ feed_to_dict,
+)
def test_calibre_opds_feed_exposes_series_metadata() -> None:
@@ -132,13 +137,73 @@ def test_calibre_opds_filters_out_unsupported_formats() -> None:
+
+ nav-author
+ Authors (A)
+
+
"""
feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
identifiers = {entry.id for entry in feed.entries}
- assert identifiers == {"pdf-book", "epub-book"}
+ assert identifiers == {"pdf-book", "epub-book", "nav-author"}
for entry in feed.entries:
- assert entry.download is not None
- assert entry.download.href.endswith((".pdf", ".epub"))
+ if entry.id.startswith("nav-"):
+ assert entry.download is None
+ assert entry.links, "Expected navigation entry to preserve links"
+ else:
+ assert entry.download is not None
+ assert entry.download.href.endswith((".pdf", ".epub"))
+
+
+def test_calibre_opds_navigation_entries_without_download_are_preserved() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
+ catalog
+ Example Catalog
+
+ nav-series
+ Series
+
+
+
+ """
+
+ feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
+
+ assert [entry.id for entry in feed.entries] == ["nav-series"]
+ entry = feed.entries[0]
+ assert entry.download is None
+ assert any(link.href.endswith("/opds/series") for link in entry.links)
+
+
+def test_calibre_opds_search_filters_by_title_and_author() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ feed = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[
+ OPDSEntry(id="1", title="The Long Journey", authors=["Alice Smith"]),
+ OPDSEntry(id="2", title="Hidden Worlds", authors=["Bob Johnson"]),
+ OPDSEntry(id="3", title="Side Stories", authors=["Cara Nguyen"], series="Journey Tales"),
+ ],
+ )
+
+ filtered = client._filter_feed_entries(feed, "journey alice")
+ assert [entry.id for entry in filtered.entries] == ["1"]
+
+ filtered = client._filter_feed_entries(feed, "bob")
+ assert [entry.id for entry in filtered.entries] == ["2"]
+
+ filtered = client._filter_feed_entries(feed, "journey tales")
+ assert [entry.id for entry in filtered.entries] == ["3"]
+
+ filtered = client._filter_feed_entries(feed, "missing")
+ assert filtered.entries == []