From b2058ef3eef5626012311e026ad5d916cbcc0a14 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 22 Dec 2025 07:27:17 -0800 Subject: [PATCH] fix: Adjust URL handling in CalibreOPDSClient to support base URLs without trailing slashes --- abogen/integrations/calibre_opds.py | 23 +++++++++++++++-------- tests/test_calibre_opds.py | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py index a643930..128e2ba 100644 --- a/abogen/integrations/calibre_opds.py +++ b/abogen/integrations/calibre_opds.py @@ -157,9 +157,9 @@ class CalibreOPDSClient: normalized = base_url.strip() if not normalized: raise ValueError("Calibre OPDS base URL is required") - if not normalized.endswith("/"): - normalized = f"{normalized}/" - self._base_url = normalized + # Store the original URL without forcing a trailing slash. + # Some servers (e.g., Booklore) return 404 for URLs with trailing slashes. + self._base_url = normalized.rstrip("/") self._auth = None if username: self._auth = httpx.BasicAuth(username, password or "") @@ -183,12 +183,19 @@ class CalibreOPDSClient: href = href.strip() if href.startswith("http://") or href.startswith("https://"): return href - if href.startswith("/") or href.startswith("?") or href.startswith("#"): - return urljoin(self._base_url, href) + if href.startswith("/"): + # Absolute path - join with origin only + parsed = urlparse(self._base_url) + return f"{parsed.scheme}://{parsed.netloc}{href}" + if href.startswith("?") or href.startswith("#"): + return f"{self._base_url}{href}" if href.startswith("./") or href.startswith("../"): - return urljoin(self._base_url, href) - # Ensure relative paths like "search" keep the catalog prefix - return urljoin(self._base_url, f"./{href}") + # For relative paths, we need a trailing slash for urljoin to work correctly + base_with_slash = self._base_url if self._base_url.endswith("/") else f"{self._base_url}/" + return urljoin(base_with_slash, href) + # Relative path like "search" or "catalog?page=1" - treat as sibling + base_with_slash = self._base_url if self._base_url.endswith("/") else f"{self._base_url}/" + return urljoin(base_with_slash, href) def _open_client(self) -> httpx.Client: return httpx.Client( diff --git a/tests/test_calibre_opds.py b/tests/test_calibre_opds.py index d7f3d1e..82f6bf4 100644 --- a/tests/test_calibre_opds.py +++ b/tests/test_calibre_opds.py @@ -163,7 +163,20 @@ def test_calibre_opds_relative_urls_keep_catalog_prefix() -> None: assert client._make_url("search") == "http://example.com/opds/search" assert client._make_url("books/sample.epub") == "http://example.com/opds/books/sample.epub" assert client._make_url("/cover/1") == "http://example.com/cover/1" - assert client._make_url("?page=2") == "http://example.com/opds/?page=2" + assert client._make_url("?page=2") == "http://example.com/opds?page=2" + + +def test_calibre_opds_base_url_without_trailing_slash() -> None: + """Ensure the client works with base URLs that don't have trailing slashes.""" + client = CalibreOPDSClient("http://example.com/api/v1/opds") + + # Base URL should be stored without trailing slash + assert client._base_url == "http://example.com/api/v1/opds" + # Relative paths should resolve as siblings to the base URL + assert client._make_url("catalog") == "http://example.com/api/v1/opds/catalog" + assert client._make_url("search?q=test") == "http://example.com/api/v1/opds/search?q=test" + assert client._make_url("/api/v1/opds/books") == "http://example.com/api/v1/opds/books" + assert client._make_url("?page=2") == "http://example.com/api/v1/opds?page=2" def test_calibre_opds_filters_out_unsupported_formats() -> None: