"""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
# CWA's anonymous browsing. With it, the fake answers every request, with or without a login.
self.anonymous = False
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'' if next_href else ""
return (
f''
f"Fake CWA{link}{''.join(entries)}"
).encode()
def _book_entry(self, b: FakeBook) -> str:
authors = "".join(f"{escape(a)}" for a in b.authors)
publisher = f"{escape(b.publisher)}" if b.publisher else ""
link = (
(
f''
)
if b.epub is not None
else ""
)
return f"{escape(b.title)}urn:uuid:{b.uuid}{authors}{publisher}{link}"
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.anonymous and 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"{escape(n)}/opds/publisher/{i}"
f''
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")