mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Implement filtering of OPDS feed entries and preserve navigation links
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
@@ -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 == []
|
||||||
|
|||||||
Reference in New Issue
Block a user