Share the checks and the job builders of the orchestrator

One function checks a whole number of a request, and one checks the
metadata field names. save_settings takes the settings by name. A rebuild
job and a story from an epub each have one builder.
This commit is contained in:
tim
2026-10-03 08:09:41 +02:00
parent 89709f875a
commit 2ada3b3d7e
3 changed files with 98 additions and 95 deletions
+82 -80
View File
@@ -66,6 +66,25 @@ def default_locked_fields() -> list[str]:
return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS]
def whole_number(value: Any, name: str, most: int | None = None, negative: bool = False) -> int:
"""A value of a request as a whole number. A wrong value is an error of the request."""
try:
number = int(value)
except (TypeError, ValueError):
raise ValueError(f"{name} must be a whole number") from None
if number < 0 and not negative:
raise ValueError(f"{name} cannot be negative")
if most is not None and number > most:
raise ValueError(f"{name} cannot be more than {most}")
return number
def _check_fields(fields: Any) -> None:
bad = [f for f in fields if f not in METADATA_FIELDS]
if bad:
raise ValueError(f"Unknown metadata fields: {bad}")
# Queueing
@@ -104,9 +123,7 @@ def queue_story(
raise ValueError("Use the library import for this mode")
if mode_enum == DownloadMode.RECHECK and not chapter_ids:
raise ValueError("Select at least one chapter to recheck")
bad = [f for f in force_fields or [] if f not in METADATA_FIELDS]
if bad:
raise ValueError(f"Unknown metadata fields: {bad}")
_check_fields(force_fields or [])
job = Job(
mode=mode_enum,
story_id=story_id,
@@ -118,6 +135,10 @@ def queue_story(
return job_queue.add(job).to_dict()
def _queue_rebuild(story: dict[str, Any]) -> dict[str, Any]:
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story["id"], title=story["title"])).to_dict()
def queue_import(books: list[dict[str, Any]], check: bool = True) -> list[dict[str, Any]]:
"""Queue imports of library books, given as {"id": ..., "uuid": ..., "title": ...}."""
jobs = []
@@ -136,10 +157,15 @@ def queue_import(books: list[dict[str, Any]], check: bool = True) -> list[dict[s
return jobs
def _epub_story_url(path: Path) -> tuple[str, str] | None:
"""The story URL and the site of an epub from FanFicFare, or None."""
return next((n for n in map(fff.normalize_url, epub_urls(path)) if n), None)
def queue_epub_file(path: Path, filename: str, story_id: int | None = None, check: bool = True) -> dict[str, Any]:
"""Queue an import of an uploaded epub. A file without a story URL, or of another story, fails at once."""
try:
normal = next((n for n in map(fff.normalize_url, epub_urls(path)) if n), None)
normal = _epub_story_url(path)
if not normal:
raise StoryError(f"{filename} has no supported story URL. Only epubs from FanFicFare can be imported.")
story = store.get_story_by_url(normal[0])
@@ -266,15 +292,11 @@ def update_story_settings(
"""Change the settings that are given. A recheck_days of 0 turns the full rechecks off."""
story = _require_story(story_id)
if recheck_days is not None:
days = int(recheck_days)
if days < 0:
raise ValueError("The number of days cannot be negative")
days = whole_number(recheck_days, "The number of days")
store.set_setting(story_id, "recheck_days", days or None)
changes: dict[str, Any] = {}
if locked_fields is not None:
bad = [f for f in locked_fields if f not in METADATA_FIELDS]
if bad:
raise ValueError(f"Unknown metadata fields: {bad}")
_check_fields(locked_fields)
changes["locked_fields"] = sorted(set(locked_fields))
# An unlocked field takes the site value again.
unlocked = set(story["locked_fields"]) - set(locked_fields)
@@ -317,20 +339,18 @@ def edit_metadata(story_id: int, values: dict[str, Any]) -> dict[str, Any]:
build uses the values.
"""
story = _require_story(story_id)
bad = [f for f in values if f not in METADATA_FIELDS]
if bad:
raise ValueError(f"Unknown metadata fields: {bad}")
_check_fields(values)
pinned = dict(story["pinned"])
for field, value in values.items():
if value is None:
pinned.pop(field, None)
else:
pinned[field] = _edited_value(field, value)
title = _display_title(story, pinned, story["site_values"].get("title") or story["title"])
title = _display_title(pinned, story["site_values"].get("title") or story["title"])
store.update_story(story_id, pinned=pinned, title=title)
queued = None
if story["metahtml"] and (story["calibre_id"] or story["ingested_at"]):
queued = job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=title)).to_dict()
queued = _queue_rebuild({**story, "title": title})
return {"story": story_view(story_id), "job": queued}
@@ -364,58 +384,38 @@ def get_settings() -> dict[str, Any]:
}
def _validate_job_list_value(value: Any, name: str, maximum: int) -> int:
try:
n = int(value)
except (TypeError, ValueError) as e:
raise ValueError(f"{name} must be a whole number") from e
if n < 0:
raise ValueError(f"{name} cannot be negative")
if n > maximum:
raise ValueError(f"{name} cannot be more than {maximum}")
return n
# The settings that save_settings takes.
SETTINGS = ("recent_chapters", "personal_ini", "link_books", "hold_new_links", "pause_uploads", *JOB_LIST_DEFAULTS)
def save_settings(
recent_chapters: Any = None,
personal_ini: str | None = None,
hold_new_links: bool | None = None,
pause_uploads: bool | None = None,
story_jobs_hours: Any = None,
story_jobs_count: Any = None,
main_jobs_hours: Any = None,
main_jobs_count: Any = None,
link_books: bool | None = None,
) -> dict[str, Any]:
"""Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini.
def save_settings(**changes: Any) -> dict[str, Any]:
"""Change the settings that are given, by the names in SETTINGS. A value of None changes nothing.
Every value is checked before the first one is stored, so a bad value changes nothing.
Returns all settings, and FanFicFare's warnings about personal.ini. Every value is checked before the first
one is stored, so a bad value changes nothing.
"""
unknown = set(changes) - set(SETTINGS)
if unknown:
raise ValueError(f"Unknown settings: {sorted(unknown)}")
changes = {name: value for name, value in changes.items() if value is not None}
link_books, personal_ini = changes.get("link_books"), changes.get("personal_ini")
if link_books is not None and not isinstance(link_books, bool):
raise ValueError("Link books to CWA library must be true or false")
numbers: dict[str, int] = {}
if recent_chapters is not None:
try:
numbers["recent_chapters"] = int(recent_chapters)
except (TypeError, ValueError) as e:
raise ValueError("The number of recent chapters must be a whole number") from e
if numbers["recent_chapters"] < 0:
raise ValueError("The number of recent chapters cannot be negative")
for name, value, maximum in (
("story_jobs_hours", story_jobs_hours, config.JOB_LIST_MAX_HOURS),
("story_jobs_count", story_jobs_count, config.JOB_LIST_MAX_COUNT),
("main_jobs_hours", main_jobs_hours, config.JOB_LIST_MAX_HOURS),
("main_jobs_count", main_jobs_count, config.JOB_LIST_MAX_COUNT),
):
if value is not None:
numbers[name] = _validate_job_list_value(value, name, maximum)
if "recent_chapters" in changes:
numbers["recent_chapters"] = whole_number(changes["recent_chapters"], "The number of recent chapters")
for name in JOB_LIST_DEFAULTS:
if name in changes:
most = config.JOB_LIST_MAX_HOURS if name.endswith("hours") else config.JOB_LIST_MAX_COUNT
numbers[name] = whole_number(changes[name], name, most)
warnings = fff.check_personal_ini(personal_ini) if personal_ini is not None else []
released = False
if link_books is not None and _linking_available():
released = _link_books() != link_books
store.set_setting(None, "link_books", link_books)
for name, value in (("hold_new_links", hold_new_links), ("pause_uploads", pause_uploads)):
for name in ("hold_new_links", "pause_uploads"):
value = changes.get(name)
if value is None or (name == "pause_uploads" and config.INGEST_DIR is None):
continue
released = released or (bool(store.get_setting(None, name)) and not value)
@@ -508,7 +508,7 @@ def set_chapter_revision(chapter_id: int, rev: int) -> dict[str, Any]:
if not chapter or store.revision_html(chapter_id, rev) is None:
raise KeyError("No such chapter revision")
store.update_chapter(chapter_id, current_rev=rev, changed_at=now())
return queue_story(chapter["story_id"], DownloadMode.REBUILD.value)
return _queue_rebuild(_require_story(chapter["story_id"]))
def delete_revision(chapter_id: int, rev: int) -> list[dict[str, Any]]:
@@ -532,7 +532,7 @@ def set_chapters_included(story_id: int, chapter_ids: list[int], included: bool)
chapter = store.get_chapter(cid)
if chapter and chapter["story_id"] == story_id:
store.update_chapter(cid, included=int(included))
return queue_story(story_id, DownloadMode.REBUILD.value)
return _queue_rebuild(_require_story(story_id))
def move_chapters(story_id: int, chapter_ids: list[int], after_id: int | None) -> dict[str, Any]:
@@ -551,7 +551,7 @@ def move_chapters(story_id: int, chapter_ids: list[int], after_id: int | None) -
return rest[:at] + moving + rest[at:]
store.reorder_chapters(story_id, arrange)
return queue_story(story_id, DownloadMode.REBUILD.value)
return _queue_rebuild(_require_story(story_id))
def delete_chapters(story_id: int, chapter_ids: list[int]) -> dict[str, Any]:
@@ -563,7 +563,7 @@ def delete_chapters(story_id: int, chapter_ids: list[int]) -> dict[str, Any]:
if len(deleting) == len(order):
raise ValueError("A story must keep at least one chapter. To remove the story, stop tracking it")
store.delete_chapters(story_id, deleting)
return queue_story(story_id, DownloadMode.REBUILD.value)
return _queue_rebuild(_require_story(story_id))
def link_story(story_id: int, book_uuid: str | None) -> None:
@@ -596,7 +596,7 @@ def approve_upload(story_id: int, book_uuid: str | None) -> dict[str, Any] | Non
raise ValueError("The library link of this story changed. Reload the page, and check the link again.")
store.set_setting(story_id, "unapproved_link", None)
if _can_upload() and store.get_setting(story_id, "upload_held") and story["metahtml"]:
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])).to_dict()
return _queue_rebuild(story)
return None
@@ -627,7 +627,7 @@ def build_for_download(story_id: int) -> tuple[BinaryIO, str]:
"""The epub of a story for a browser download, as an open file. Bindery builds it only if the book changed
after the last build."""
story = _require_story(story_id)
overrides = _epub_overrides(story, story["pinned"])
overrides = _epub_overrides(story["pinned"])
key = _build_key(story, overrides, story["site_values"])
# The file is opened under the lock, so a new build cannot delete it first.
with _build_lock:
@@ -664,11 +664,11 @@ def _resolve_metadata(story: dict[str, Any], site: dict[str, Any], force_fields:
return pinned
def _epub_overrides(story: dict[str, Any], pinned: dict[str, Any]) -> dict[str, Any]:
def _epub_overrides(pinned: dict[str, Any]) -> dict[str, Any]:
return {f: v for f, v in pinned.items() if f in METADATA_FIELDS}
def _display_title(story: dict[str, Any], pinned: dict[str, Any], site_title: str) -> str:
def _display_title(pinned: dict[str, Any], site_title: str) -> str:
return pinned.get("title") or site_title
@@ -864,7 +864,7 @@ def _release_held_uploads() -> None:
for story_id in store.story_settings("upload_held"):
story = store.get_story(story_id)
if story and story["metahtml"]:
job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"]))
_queue_rebuild(story)
def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None:
@@ -943,7 +943,7 @@ def adopt_build_keys() -> None:
story = _require_story(story_id)
if not story["metahtml"]:
continue
key = _build_key(story, _epub_overrides(story, story["pinned"]), story["site_values"])
key = _build_key(story, _epub_overrides(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)
@@ -997,6 +997,16 @@ def _import_epub(story_id: int, contents: fff.EpubContents) -> int:
return len(new)
def _story_from_epub(
url: str, site: str, title: str, meta: dict[str, Any], book_id: int | None = None, book_uuid: str | None = None
) -> int:
"""Create a story with the metadata of its epub. The locked fields keep the values of the epub."""
locked = default_locked_fields()
story_id = store.create_story(url, site, title, " & ".join(meta["authors"]), locked, book_id, book_uuid)
store.update_story(story_id, pinned={f: _field_value(f, meta) for f in locked}, status="ok")
return story_id
def _run_import(job: Job) -> str:
tracked = store.get_story_by_calibre_id(job.calibre_id)
# A tracked story without chapters is left from an import that stopped, so the import goes on.
@@ -1009,7 +1019,7 @@ def _run_import(job: Job) -> str:
_progress(job, "Downloading epub from CWA")
epub = cwa().download_epub(source, config.TMP_DIR / f"import-{job.calibre_id}-{uuid.uuid4().hex[:8]}.epub")
try:
normal = next((n for n in map(fff.normalize_url, epub_urls(epub)) if n), None)
normal = _epub_story_url(epub)
if not normal:
raise StoryError(f"{job.title} has no supported story URL")
meta = epub_metadata(epub)
@@ -1021,12 +1031,8 @@ def _run_import(job: Job) -> str:
return f"Linked to tracked story {story['title']}"
contents = _read_epub(epub, title)
if story is None:
locked = default_locked_fields()
story_id = store.create_story(
normal[0], normal[1], title, " & ".join(meta["authors"]), locked, job.calibre_id, book_uuid or None
)
pinned = {f: _field_value(f, meta) for f in locked}
store.update_story(story_id, pinned=pinned, ingested_at=now(), status="ok")
story_id = _story_from_epub(normal[0], normal[1], title, meta, job.calibre_id, book_uuid or None)
store.update_story(story_id, ingested_at=now())
story = _require_story(story_id)
story_id = story["id"]
job_queue.update(job, story_id=story_id)
@@ -1047,11 +1053,7 @@ def _run_import_file(job: Job) -> str:
story = store.get_story(job.story_id) if job.story_id else store.get_story_by_url(job.url)
if story is None:
meta = epub_metadata(path)
locked = default_locked_fields()
story_id = store.create_story(
job.url, fff.normalize_url(job.url)[1], meta["title"] or job.url, " & ".join(meta["authors"]), locked
)
store.update_story(story_id, pinned={f: _field_value(f, meta) for f in locked}, status="ok")
story_id = _story_from_epub(job.url, fff.normalize_url(job.url)[1], meta["title"] or job.url, meta)
_link_changed(story_id)
story = _require_story(story_id)
job_queue.update(job, story_id=story["id"], title=story["title"])
@@ -1069,7 +1071,7 @@ def _run_import_file(job: Job) -> str:
Job(mode=DownloadMode.NEW, story_id=story["id"], title=story["title"], options={"build": bool(added)})
)
elif added and story["metahtml"]:
job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story["id"], title=story["title"]))
_queue_rebuild(story)
return message
@@ -1077,7 +1079,7 @@ def _run_rebuild(job: Job) -> str:
story = _require_story(job.story_id)
job_queue.update(job, title=story["title"])
pinned = _resolve_metadata(story, story["site_values"], [])
overrides = _epub_overrides(story, pinned)
overrides = _epub_overrides(pinned)
key = _build_key(story, overrides, story["site_values"])
force = bool(job.options.get("force"))
if not force and key == _done_key(story["id"]):
@@ -1319,8 +1321,8 @@ def _run_site_job(job: Job) -> str:
# 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)
title = _display_title(pinned, snap.title)
overrides = _epub_overrides(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 == _done_key(story_id):
+2 -15
View File
@@ -105,10 +105,7 @@ def _list(data: dict[str, Any], name: str) -> list[Any]:
def _int(value: Any, name: str) -> int:
try:
return int(value)
except (TypeError, ValueError):
raise ValueError(f"{name} must be a whole number") from None
return orchestrator.whole_number(value, name, negative=True)
def _ids(data: dict[str, Any], name: str = "chapter_ids") -> list[int]:
@@ -280,17 +277,7 @@ def settings() -> Any:
if request.method == "PUT":
_admin_only()
data = _json()
return orchestrator.save_settings(
data.get("recent_chapters"),
data.get("personal_ini"),
data.get("hold_new_links"),
data.get("pause_uploads"),
data.get("story_jobs_hours"),
data.get("story_jobs_count"),
data.get("main_jobs_hours"),
data.get("main_jobs_count"),
link_books=data.get("link_books"),
)
return orchestrator.save_settings(**{name: data.get(name) for name in orchestrator.SETTINGS})
settings = orchestrator.get_settings()
if not auth.is_admin(g.identity):
del settings["personal_ini"] # it can hold passwords for story sites
+14
View File
@@ -199,3 +199,17 @@ def test_a_story_change_does_not_ask_cwa_for_the_book(client, story, monkeypatch
assert response.json["recheck_days"] == 4
assert "chapters" not in response.json
cwa.get_book.assert_not_called()
def test_settings_are_saved_by_name(client):
with pytest.raises(ValueError, match="Unknown settings: \\['recent'\\]"):
o.save_settings(recent=3)
# The route passes only the known settings on.
response = client.put("/api/settings", json={"recent": 3, "recent_chapters": 4, "main_jobs_count": 9}, headers=H)
assert (response.status_code, response.json["recent_chapters"], response.json["main_jobs_count"]) == (200, 4, 9)
for data, error in [
({"recent_chapters": -1}, "The number of recent chapters cannot be negative"),
({"main_jobs_count": 501}, "main_jobs_count cannot be more than 500"),
({"story_jobs_hours": "x"}, "story_jobs_hours must be a whole number"),
]:
assert client.put("/api/settings", json=data, headers=H).json == {"error": error}