Find books through CWA's OPDS feed and use its ingest folder

Bindery mounts no CWA database. It searches the OPDS feed by title and
reads the story URL inside each candidate epub to find the library book.
Updates go to the ingest folder with a .cwa.json sidecar, so CWA replaces
the epub of the existing book.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent c30622e3a0
commit 312dc3efc5
6 changed files with 869 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
"""A small stand-in for CWA's OPDS feed, book JSON and downloads."""
import base64
import json
import threading
from dataclasses import dataclass, field
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
@dataclass
class FakeBook:
id: int
uuid: str
title: str
authors: list[str] = field(default_factory=lambda: ["Writer"])
publisher: str = ""
epub: bytes | None = None
class FakeCwa:
def __init__(self, username: str = "bindery", password: str = "secret", page_size: int = 50) -> None:
self.username, self.password, self.page_size = username, password, page_size
self.books: list[FakeBook] = []
self.requests: list[str] = []
# CWA's "Show Publisher Section" setting. Without it, the publisher feed is 404.
self.publisher_view = True
fake = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # quiet
pass
def do_GET(self):
fake.requests.append(self.path)
fake.handle(self)
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self) -> None:
if self.server.socket.fileno() == -1:
return
self.server.shutdown()
self.server.server_close()
def _authorized(self, h) -> bool:
header = h.headers.get("Authorization", "")
if not header.startswith("Basic "):
return False
user, _, password = base64.b64decode(header[6:]).decode().partition(":")
return user == self.username and password == self.password
def _send(self, h, status: int, body: bytes, ctype: str) -> None:
h.send_response(status)
h.send_header("Content-Type", ctype)
h.send_header("Content-Length", str(len(body)))
h.end_headers()
h.wfile.write(body)
def _feed(self, entries: list[str], next_href: str | None = None) -> bytes:
link = f'<link rel="next" href="{escape(next_href)}"/>' if next_href else ""
return (
f'<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom">'
f"<title>Fake CWA</title>{link}{''.join(entries)}</feed>"
).encode()
def _book_entry(self, b: FakeBook) -> str:
authors = "".join(f"<author><name>{escape(a)}</name></author>" for a in b.authors)
publisher = f"<publisher><name>{escape(b.publisher)}</name></publisher>" if b.publisher else ""
link = (
(
f'<link rel="http://opds-spec.org/acquisition" href="/opds/download/{b.id}/epub/" '
f'type="application/epub+zip"/>'
)
if b.epub is not None
else ""
)
return f"<entry><title>{escape(b.title)}</title><id>urn:uuid:{b.uuid}</id>{authors}{publisher}{link}</entry>"
def _paged(self, h, books: list[FakeBook], path: str, query: dict) -> None:
offset = int(query.get("offset", ["0"])[0])
page = books[offset : offset + self.page_size]
next_href = f"{path}?offset={offset + self.page_size}" if offset + self.page_size < len(books) else None
self._send(h, 200, self._feed([self._book_entry(b) for b in page], next_href), "application/atom+xml")
def handle(self, h) -> None:
if not self._authorized(h):
self._send(h, 401, b"Unauthorized", "text/plain")
return
url = urlparse(h.path)
query = parse_qs(url.query)
path = url.path
if path == "/opds":
self._send(h, 200, self._feed([]), "application/atom+xml")
elif path == "/opds/search":
term = query.get("query", [""])[0].lower()
hits = [b for b in self.books if term in b.title.lower() or any(term in a.lower() for a in b.authors)]
# CWA returns every search result on one page.
self._send(h, 200, self._feed([self._book_entry(b) for b in hits]), "application/atom+xml")
elif path.startswith("/opds/publisher") and not self.publisher_view:
self._send(h, 404, b"Not Found", "text/plain")
elif path == "/opds/publisher":
names = sorted({b.publisher for b in self.books if b.publisher})
entries = [
f"<entry><title>{escape(n)}</title><id>/opds/publisher/{i}</id>"
f'<link rel="subsection" href="/opds/publisher/{i}"/></entry>'
for i, n in enumerate(names)
]
self._send(h, 200, self._feed(entries), "application/atom+xml")
elif path.startswith("/opds/publisher/"):
names = sorted({b.publisher for b in self.books if b.publisher})
name = names[int(path.rsplit("/", 1)[1])]
self._paged(h, [b for b in self.books if b.publisher == name], path, query)
elif path.startswith("/ajax/book/"):
uuid = path.rsplit("/", 1)[1]
book = next((b for b in self.books if b.uuid == uuid), None)
if not book:
self._send(h, 200, b"", "text/html")
return
data = {
"uuid": book.uuid,
"application_id": book.id,
"title": book.title,
"authors": book.authors,
"formats": ["EPUB"] if book.epub is not None else [],
}
self._send(h, 200, json.dumps(data).encode(), "application/json")
elif path.startswith("/opds/download/"):
book_id = int(path.split("/")[3])
book = next((b for b in self.books if b.id == book_id and b.epub is not None), None)
if not book:
self._send(h, 404, b"Not found", "text/plain")
return
self._send(h, 200, book.epub, "application/epub+zip")
else:
self._send(h, 404, b"Not found", "text/plain")