feat: Implement filtering of OPDS feed entries and preserve navigation links

This commit is contained in:
JB
2025-11-11 17:37:11 -08:00
parent 028384e6ee
commit aba72524ce
2 changed files with 132 additions and 11 deletions
+63 -7
View File
@@ -209,13 +209,15 @@ class CalibreOPDSClient:
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for path, params in candidates: for path, params in candidates:
try: 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: except CalibreOPDSError as exc:
last_error = exc last_error = exc
continue continue
if last_error is not None: if last_error is not None:
raise last_error raise last_error
return self.fetch_feed() feed = self.fetch_feed()
return self._filter_feed_entries(feed, cleaned)
def download(self, href: str) -> DownloadedResource: def download(self, href: str) -> DownloadedResource:
if not href: if not href:
@@ -274,11 +276,13 @@ class CalibreOPDSClient:
feed_title = root.findtext("atom:title", default=None, namespaces=NS) feed_title = root.findtext("atom:title", default=None, namespaces=NS)
links = self._parse_links(root.findall("atom:link", NS), base_url) 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)] parsed_entries = [self._parse_entry(node, base_url) for node in root.findall("atom:entry", NS)]
entries = [ entries: List[OPDSEntry] = []
entry for entry in parsed_entries:
for entry in parsed_entries if entry.download and self._is_supported_download(entry.download):
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) return OPDSFeed(id=feed_id, title=feed_title, entries=entries, links=links)
def _parse_entry(self, node: ET.Element, base_url: str) -> OPDSEntry: def _parse_entry(self, node: ET.Element, base_url: str) -> OPDSEntry:
@@ -620,6 +624,58 @@ class CalibreOPDSClient:
# No valid acquisition-style link exposed # No valid acquisition-style link exposed
return None 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]: def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
"""Helper used by APIs to convert a feed into JSON-serialisable payloads.""" """Helper used by APIs to convert a feed into JSON-serialisable payloads."""
+69 -4
View File
@@ -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: def test_calibre_opds_feed_exposes_series_metadata() -> None:
@@ -132,13 +137,73 @@ def test_calibre_opds_filters_out_unsupported_formats() -> None:
<link rel=\"http://opds-spec.org/acquisition\" <link rel=\"http://opds-spec.org/acquisition\"
href=\"books/sample.epub\" /> href=\"books/sample.epub\" />
</entry> </entry>
<entry>
<id>nav-author</id>
<title>Authors (A)</title>
<link rel=\"http://opds-spec.org/subsection\"
href=\"/opds/authors/a\"
type=\"application/atom+xml;profile=opds-catalog\" />
</entry>
</feed> </feed>
""" """
feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog") feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
identifiers = {entry.id for entry in feed.entries} 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: for entry in feed.entries:
assert entry.download is not None if entry.id.startswith("nav-"):
assert entry.download.href.endswith((".pdf", ".epub")) 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 = """<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<feed xmlns=\"http://www.w3.org/2005/Atom\">
<id>catalog</id>
<title>Example Catalog</title>
<entry>
<id>nav-series</id>
<title>Series</title>
<link rel=\"http://opds-spec.org/subsection\"
href=\"/opds/series\"
type=\"application/atom+xml;profile=opds-catalog\" />
</entry>
</feed>
"""
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 == []