Files
bindery/bindery/ingest.py
T
tim 312dc3efc5 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.
2026-09-27 23:38:28 +02:00

69 lines
2.8 KiB
Python

"""Hand-off to CWA through its ingest folder. CWA's own calibredb does every write.
- A new story goes in as a plain epub, and CWA imports it as a new book.
- An update goes in with a sidecar file, <name>.epub.cwa.json, that holds
{"action": "add_format", "book_id": N}. CWA then replaces the epub of book N.
CWA's own web UI uses the same sidecar, in cps/editbooks.py.
"""
import json
import logging
import os
import re
import shutil
import uuid
from pathlib import Path
logger = logging.getLogger(__name__)
# CWA shortens ingest file names over 150 characters. That would break the
# match between an epub and its sidecar file, so names stay well under it.
MAX_NAME_LENGTH = 100
def sanitize_filename(name: str) -> str:
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", name).strip(" .")
return name[:MAX_NAME_LENGTH].strip(" .") or "story"
class IngestFolder:
def __init__(self, path: Path) -> None:
self.path = Path(path)
def _place(self, epub: Path, final_path: Path) -> Path:
"""Copy an epub in under a temporary .part name, then rename it.
CWA ignores .part files, and reacts to the rename. A file without an
extension is not safe: CWA deletes files it cannot import.
"""
partial = final_path.with_name(final_path.name + ".part")
shutil.copyfile(epub, partial)
os.replace(partial, final_path)
return final_path
def ingest(self, epub: Path, name: str) -> Path:
"""Send a new book to CWA."""
self.path.mkdir(parents=True, exist_ok=True)
# A short unique suffix, so a file that CWA has not imported yet is never replaced.
stem = sanitize_filename(f"{name[:80]} {uuid.uuid4().hex[:6]}")
final_path = self._place(epub, self.path / f"{stem}.epub")
logger.info(f"Sent {final_path.name} to the ingest folder as a new book")
return final_path
def send_update(self, epub: Path, book_id: int, name: str) -> Path:
"""Send a new epub for an existing book. CWA replaces the book's epub."""
self.path.mkdir(parents=True, exist_ok=True)
# A unique name, so two updates of one book never share a sidecar file.
stem = sanitize_filename(f"{name[:60]} {book_id}-{uuid.uuid4().hex[:8]}")
final_path = self.path / f"{stem}.epub"
manifest = final_path.with_name(final_path.name + ".cwa.json")
# The sidecar must exist before the epub arrives. CWA skips .cwa.json files.
manifest.write_text(json.dumps({"action": "add_format", "book_id": int(book_id)}), encoding="utf-8")
try:
self._place(epub, final_path)
except Exception:
manifest.unlink(missing_ok=True)
raise
logger.info(f"Sent {final_path.name} to the ingest folder as an update of book {book_id}")
return final_path