Build and send a book only when it changed

A key of the chapters, rules and metadata of a story tells if its book
changed. A check without a change no longer builds the book again, also
for a book that waits for an upload. A held book goes out without a new
build. The Rebuild button still builds and sends in each case.
This commit is contained in:
tim
2026-10-02 23:37:33 +02:00
parent f961e4781e
commit 7a36e26751
9 changed files with 367 additions and 55 deletions
+9 -4
View File
@@ -238,16 +238,21 @@ def word_count(chapters: list[dict[str, Any]]) -> int:
return sum(len(block.split()) for c in chapters for block in html_to_blocks(c["html"]))
def effective_overrides(overrides: dict[str, Any]) -> dict[str, Any]:
"""The kept values that replace a site value. An empty title, author list or language does not."""
return {k: v for k, v in overrides.items() if v or k not in ("title", "authors", "languages")}
def apply_overrides(story, overrides: dict[str, Any]) -> None:
"""Put the kept values of locked fields into FanFicFare's story metadata.
The epub writer takes the OPF metadata from these entries, and CWA takes
the book record from the OPF on the first import.
"""
for key, value in overrides.items():
if key == "title" and value:
for key, value in effective_overrides(overrides).items():
if key == "title":
story.setMetadata("title", value)
elif key == "authors" and value:
elif key == "authors":
for n, author in enumerate(value):
story.addToList("author", author, clear=(n == 0))
elif key == "comments":
@@ -263,7 +268,7 @@ def apply_overrides(story, overrides: dict[str, Any]) -> None:
story.setMetadata("datePublished", datetime.fromisoformat(value) if value else None)
except ValueError:
logger.warning(f"Ignoring a kept publication date that is not a date: {value!r}")
elif key == "languages" and value:
elif key == "languages":
story.setMetadata("langcode", value)
elif key == "tags":
tags = list(value or [])
+127 -42
View File
@@ -1,6 +1,7 @@
"""Download modes, metadata locks, delivery to CWA, and the job threads."""
import hashlib
import json
import logging
import re
import threading
@@ -94,8 +95,13 @@ def queue_urls(urls: list[str]) -> tuple[list[dict[str, Any]], list[str]]:
def queue_story(
story_id: int, mode: str, chapter_ids: list[int] | None = None, force_fields: list[str] | None = None
story_id: int,
mode: str,
chapter_ids: list[int] | None = None,
force_fields: list[str] | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Queue a job for a story. With `force`, a rebuild also builds and sends a book that did not change."""
story = _require_story(story_id)
mode_enum = DownloadMode(mode)
if mode_enum == DownloadMode.IMPORT:
@@ -111,6 +117,7 @@ def queue_story(
title=story["title"],
chapter_ids=sorted(int(c) for c in chapter_ids or []),
force_fields=sorted(force_fields or []),
options={"force": True} if force and mode_enum == DownloadMode.REBUILD else {},
)
return job_queue.add(job).to_dict()
@@ -674,6 +681,60 @@ def _build(story: dict[str, Any], overrides: dict[str, Any], out_path: Path) ->
)
# Built books. The key of a story is a hash of all that goes into its epub, so the book changed when the key
# changed. The name of the built file holds the key. The story settings "built_key" and "sent_key" are the keys of
# the last build and of the book that went to CWA.
_build_lock = threading.Lock()
def _book_metadata(site: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
"""The metadata of the book: the site values, with the kept values in their place."""
meta = dict(site)
for field, value in fff.effective_overrides(overrides).items():
if field == "series":
meta["series"], meta["series_index"] = value or ("", None)
else:
meta[field] = value
return meta
def _build_key(story: dict[str, Any], overrides: dict[str, Any], site: dict[str, Any]) -> str:
story_id = story["id"]
digest = hashlib.sha256()
state = [
story["url"],
_book_metadata(site, overrides),
store.rules(None),
store.rules(story_id),
_paragraphs(story_id),
store.image_sizes(story_id),
]
digest.update(json.dumps(state, sort_keys=True, default=str).encode())
for c in store.chapters_for_build(story_id):
digest.update(json.dumps([c["url"], c["title"], c["html"]]).encode())
return digest.hexdigest()
def _built_epub(story: dict[str, Any], overrides: dict[str, Any], key: str, force: bool = False) -> Path:
"""The epub of the story for this key. Bindery builds it only if it has no file for the key."""
path = config.TMP_DIR / f"story-{story['id']}-{key[:16]}.epub"
with _build_lock:
if force or not path.exists():
# A build under another name first, so a download never reads half a book.
partial = config.TMP_DIR / f"building-{uuid.uuid4().hex}.epub"
try:
_build(story, overrides, partial)
partial.replace(path)
finally:
partial.unlink(missing_ok=True)
for old in config.TMP_DIR.glob(f"story-{story['id']}-*.epub"):
if old != path:
old.unlink(missing_ok=True)
store.set_setting(story["id"], "built_key", key)
return path
def _find_book(story_url: str, titles: list[str | None]) -> tuple[Book, Path] | None:
"""Search CWA for the book of a story. Returns the book and its downloaded epub."""
linked = {s["calibre_id"] for s in store.list_stories() if s["calibre_id"]}
@@ -744,6 +805,8 @@ def _uploads_paused() -> bool:
def _link_changed(story_id: int) -> None:
store.set_setting(story_id, "unapproved_link", True)
# Another book, or no book, does not have the epub that Bindery sent.
store.set_setting(story_id, "sent_key", None)
def _needs_approval(story_id: int) -> bool:
@@ -773,10 +836,6 @@ def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | No
def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, title: str, author: str) -> str:
held = _hold_reason(story, book)
if held:
store.set_setting(story["id"], "upload_held", True)
return held
# With linking on, do not create a duplicate when the library book cannot be found.
if _link_books() and not book and story["ingested_at"]:
raise StoryError(NOT_FOUND)
@@ -794,6 +853,44 @@ def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, tit
return message
def _send(
story: dict[str, Any], overrides: dict[str, Any], key: str, title: str, author: str, force: bool = False
) -> tuple[str, bool]:
"""Build the book if it changed, and deliver it. Returns the message, and whether the book changed.
A held book that did not change is not built again. With `force`, Bindery builds the book in each case.
"""
story_id = story["id"]
book = _linked_book(story)
held = _hold_reason(story, book)
changed = key != store.get_setting(story_id, "built_key")
if held:
if changed or force:
_built_epub(story, overrides, key, force)
store.set_setting(story_id, "upload_held", True)
return held, changed
message = _deliver(story, _built_epub(story, overrides, key, force), book, title, author)
store.set_setting(story_id, "sent_key", key)
return message, changed
def adopt_build_keys() -> None:
"""Give the stories of an older Bindery their keys, so the first check does not send each book again."""
built = store.story_settings("built_key")
held = store.story_settings("upload_held")
for listed in store.list_stories():
story_id = listed["id"]
if story_id in built or listed["status"] == "error" or job_queue.has_active(story_id):
continue
story = _require_story(story_id)
if not story["metahtml"]:
continue
key = _build_key(story, _epub_overrides(story, story["pinned"]), story["site_values"])
store.set_setting(story_id, "built_key", key)
if (story["calibre_id"] or story["ingested_at"]) and not held.get(story_id):
store.set_setting(story_id, "sent_key", key)
# Jobs
@@ -920,12 +1017,15 @@ def _run_import_file(job: Job) -> str:
def _run_rebuild(job: Job) -> str:
story = _require_story(job.story_id)
job_queue.update(job, title=story["title"])
book = _linked_book(story)
pinned = _resolve_metadata(story, story["site_values"], [])
epub = config.TMP_DIR / f"story-{story['id']}.epub"
_build(story, _epub_overrides(story, pinned), epub)
message = _deliver(story, epub, book, story["title"], story["author"])
store.update_story(story["id"], pinned=pinned, status="ok", message=message, last_changed=now())
overrides = _epub_overrides(story, pinned)
key = _build_key(story, overrides, story["site_values"])
force = bool(job.options.get("force"))
if not force and key == store.get_setting(story["id"], "sent_key"):
return "No changes"
message, changed = _send(story, overrides, key, story["title"], story["author"], force)
changes: dict[str, Any] = {"last_changed": now()} if changed or force else {}
store.update_story(story["id"], pinned=pinned, status="ok", message=message, **changes)
return message
@@ -1149,46 +1249,27 @@ def _run_site_job(job: Job) -> str:
job, result={"new": new_count, "changed": changed, "failed": failed, "skipped": skipped, "removed": removed}
)
metadata_changed = snap.calibre != story["site_values"]
delivered = bool(story["calibre_id"] or story["ingested_at"])
# A resumed job builds the book, because the run before the restart can have stored chapters.
needs_build = (
new_count
or changed
or renamed_count
or job.force_fields
or metadata_changed
or not delivered
or job.options.get("build")
or job.restarts
or mode in (DownloadMode.FULL, DownloadMode.METADATA)
or (mode == DownloadMode.NEW and story["status"] == "error" and not failed)
)
if mode in (DownloadMode.RECHECK_ALL, DownloadMode.FULL):
store.set_setting(story_id, "last_full_recheck", now())
if not store.chapters_for_build(story_id):
raise StoryError("; ".join(summary) + ". No chapters downloaded.")
if not needs_build:
store.update_story(story_id, last_checked=now(), status="ok", message="No changes")
return "No changes. " + ", ".join(summary)
book = _linked_book(story)
# The book is built and sent only when its chapters, rules or metadata changed. A job that resumes, or that
# follows a failed job, finds the changes of the earlier run through the key.
pinned = _resolve_metadata(story, snap.calibre, job.force_fields)
title = _display_title(story, pinned, snap.title)
overrides = _epub_overrides(story, pinned)
key = _build_key(story, overrides, snap.calibre)
checked: dict[str, Any] = {"pinned": pinned, "site_values": snap.calibre, "title": title, "last_checked": now()}
if key == store.get_setting(story_id, "sent_key"):
store.update_story(story_id, **checked, status="ok", message="No changes")
return "No changes. " + ", ".join(summary)
_progress(job, "Building epub", 1.0)
epub = config.TMP_DIR / f"story-{story_id}.epub"
_build(story, _epub_overrides(story, pinned), epub)
message = _deliver(story, epub, book, title, snap.author)
store.update_story(
story_id,
pinned=pinned,
site_values=snap.calibre,
title=title,
last_checked=now(),
last_changed=now(),
message=message,
status="error" if failed else "ok",
)
message, book_changed = _send(story, overrides, key, title, snap.author)
if book_changed:
checked["last_changed"] = now()
store.update_story(story_id, **checked, message=message, status="error" if failed else "ok")
return ", ".join(summary) + ". " + message
@@ -1267,5 +1348,9 @@ def scheduler_loop() -> None:
def start_threads() -> None:
try:
adopt_build_keys()
except Exception as e:
logger.exception(f"Cannot check the built books: {e}")
for target in (download_loop, scheduler_loop):
threading.Thread(target=target, daemon=True, name=target.__name__).start()
+8
View File
@@ -388,6 +388,14 @@ class Store:
rows = conn.execute("SELECT url FROM images WHERE story_id = ? AND is_cover = 0", (story_id,))
return {r["url"] for r in rows}
def image_sizes(self, story_id: int) -> list[list[Any]]:
"""The URL, the cover mark and the size of each image, without its data."""
with self._connect() as conn:
rows = conn.execute(
"SELECT url, is_cover, LENGTH(data) FROM images WHERE story_id = ? ORDER BY url, is_cover", (story_id,)
)
return [list(r) for r in rows]
def images(self, story_id: int) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
+2 -1
View File
@@ -291,8 +291,9 @@ def delete_story(story_id: int) -> Any:
@app.post("/api/stories/<int:story_id>/download")
def download(story_id: int) -> Any:
data = _json()
# A rebuild that the user asks for also builds and sends a book that did not change.
return orchestrator.queue_story(
story_id, data.get("mode", DownloadMode.NEW.value), data.get("chapter_ids"), data.get("force_fields")
story_id, data.get("mode", DownloadMode.NEW.value), data.get("chapter_ids"), data.get("force_fields"), True
)