Do not make the ingest folder

A folder that Bindery made hid a missing volume mount. The books went
to a folder inside the container, and CWA never saw them. A missing
folder is now an error in the log at the start, and each upload fails
with a message that names the mount.
This commit is contained in:
tim
2026-10-02 23:37:33 +02:00
parent 679c1ede9f
commit 869e56eaf0
13 changed files with 81 additions and 16 deletions
+1
View File
@@ -17,6 +17,7 @@ Keep the quotes around the glob in the node command. Without them, node reads `t
Start a development server with no CWA:
```bash
mkdir -p dev/ingest
CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery
```
+6 -1
View File
@@ -69,7 +69,7 @@ Environment variables set the configuration. **Restart the container after a cha
| `SESSION_COOKIE_SECURE` | `true` if users reach Bindery only over HTTPS | `false` |
| `TRUST_PROXY` | `true` if a reverse proxy sends the client address. See "Lockouts and sessions". | `false` |
| `URL_BASE` | A path prefix, such as `/bindery`. See "Reverse proxy". | None |
| `INGEST_DIR` | The CWA ingest folder. If unset, all uploads stay paused. | None |
| `INGEST_DIR` | The CWA ingest folder. If unset, all uploads stay paused. Bindery does not make this folder. | None |
| `CONFIG_DIR` | The story database and `personal.ini` | `/config` |
| `TMP_DIR` | The folder for the built ePubs. Bindery keeps the last build of each story. | `/tmp/bindery` |
| `LOG_LEVEL` | The log level | `INFO` |
@@ -263,6 +263,10 @@ The health check at `/api/health` works with and without the prefix.
Bindery gives every book to CWA through the ingest folder. CWA makes all changes to the library.
**Bindery does not make the ingest folder.** If the folder in `INGEST_DIR` does not exist, most often
because its volume mount is missing, the log shows an error at the start and each upload fails. The
books go to CWA with the next update after you fix the mount.
1. Bindery puts the ePub of a new story into the ingest folder. CWA imports it as a new book.
2. At the next update, Bindery finds the CWA book of the story. If you set `CALIBRE_DB_PATH`, it
first looks for the story URL in the library database. Without a single match there, it searches
@@ -530,6 +534,7 @@ Without CWA, Bindery puts each new build of a story into its ingest folder as an
enough to work on downloads, chapters, rules and the web interface.
```bash
mkdir -p dev/ingest
CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery
```
+8 -2
View File
@@ -99,9 +99,15 @@ BACKUP_COUNT = 7 # daily backups to keep
def prepare() -> None:
"""Make the folders, and give the user a personal.ini to edit on first start."""
for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR, INGEST_DIR):
if directory is not None:
for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR):
directory.mkdir(parents=True, exist_ok=True)
# The ingest folder belongs to CWA. A folder that Bindery makes hides a missing volume mount, and CWA
# never sees its files.
if INGEST_DIR and not INGEST_DIR.is_dir():
logger.error(
f"The ingest folder {INGEST_DIR} does not exist. Check INGEST_DIR and the volume mount. "
"Uploads fail until the folder exists."
)
if not PERSONAL_INI.exists():
shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI)
logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}")
+11 -2
View File
@@ -26,10 +26,19 @@ def sanitize_filename(name: str) -> str:
return name[:MAX_NAME_LENGTH].strip(" .") or "story"
class IngestError(Exception):
"""The ingest folder cannot take a book."""
class IngestFolder:
def __init__(self, path: Path) -> None:
self.path = Path(path)
def _check(self) -> None:
# Bindery does not make the folder. A missing folder is most often a missing volume mount.
if not self.path.is_dir():
raise IngestError(f"The ingest folder {self.path} does not exist. Check INGEST_DIR and the volume mount.")
def _place(self, epub: Path, final_path: Path) -> Path:
"""Copy an epub in under a temporary .part name, then rename it.
@@ -43,7 +52,7 @@ class IngestFolder:
def ingest(self, epub: Path, name: str) -> Path:
"""Send a new book to CWA."""
self.path.mkdir(parents=True, exist_ok=True)
self._check()
# 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")
@@ -52,7 +61,7 @@ class IngestFolder:
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)
self._check()
# 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"
+2 -2
View File
@@ -17,7 +17,7 @@ from bindery.cwa import Book, BookFinder, CwaClient, CwaError
from bindery.diffing import diff_html, text_hash
from bindery.epub import EpubError, epub_metadata, epub_urls
from bindery.fff import StoryError
from bindery.ingest import IngestFolder
from bindery.ingest import IngestError, IngestFolder
from bindery.jobs import METADATA_FIELDS, DownloadMode, Job, QueueStatus, job_queue
from bindery.merge import merge_chapter_lists
from bindery.rules import RuleError, Step, apply_rules, compile_rules, normalize_rules
@@ -1332,7 +1332,7 @@ def _run_next_job() -> Job | None:
message = _run_job(job)
job_queue.update(job, status=QueueStatus.DONE, message=message, progress=1.0)
logger.info(f"Job {job.id} ({job.mode.value}) for {job.title}: {message}")
except (StoryError, CwaError, EpubError, RuleError, KeyError, ValueError) as e:
except (StoryError, CwaError, EpubError, IngestError, RuleError, KeyError, ValueError) as e:
_fail(job, str(e))
except Exception as e:
logger.exception(f"Job {job.id} failed: {e}")
+1 -1
View File
@@ -34,7 +34,7 @@ services:
start_period: 30s
retries: 3
volumes:
# CWA's ingest folder. New stories and updates go here.
# CWA's ingest folder. New stories and updates go here. Bindery does not make the folder.
- /path/to/cwa/ingest:/cwa-book-ingest
# The story database and personal.ini. Create the folder before the first start.
- ./config:/config
+1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
_tmp = Path(tempfile.mkdtemp(prefix="bindery-tests-"))
for name, sub in [("CONFIG_DIR", "config"), ("INGEST_DIR", "ingest"), ("TMP_DIR", "tmp")]:
os.environ[name] = str(_tmp / sub)
(_tmp / "ingest").mkdir() # Bindery does not make the ingest folder
os.environ["BINDERY_START_THREADS"] = "false"
os.environ["UPDATE_INTERVAL_HOURS"] = "0"
for name in (
+15
View File
@@ -279,3 +279,18 @@ def test_the_start_deletes_uploads_that_no_job_needs(builds, jobs, uploads, stor
orchestrator.queue_epub_file(waiting, "story.epub")
orchestrator.remove_stale_uploads()
assert list(uploads.iterdir()) == [waiting]
def test_a_missing_ingest_folder_fails_the_job_until_the_folder_exists(builds, jobs, monkeypatch, tmp_path):
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "not-mounted")
story_id = linked_story(ONE)
site(monkeypatch, ONE)
monkeypatch.setattr(orchestrator.time, "sleep", Mock())
jobs.add(Job(mode=DownloadMode.NEW, story_id=story_id))
job = orchestrator._run_next_job()
assert job.status == "error" and "does not exist. Check INGEST_DIR and the volume mount." in job.message
assert story(story_id)["status"] == "error"
assert not config.INGEST_DIR.exists()
config.INGEST_DIR.mkdir()
assert "library book 1" in update(story_id)
assert builds.call_count == 1
+1
View File
@@ -250,6 +250,7 @@ def test_library_database_failure_keeps_the_http_lookup(library_db, monkeypatch,
def test_database_book_id_reaches_the_epub_sidecar(client, library_db, monkeypatch, tmp_path, saved_id, saved_uuid):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
config.INGEST_DIR.mkdir(exist_ok=True)
remote = CwaClient("")
remote.session.get = Mock(side_effect=AssertionError("No HTTP lookup needed"))
monkeypatch.setattr(orchestrator, "cwa", lambda: remote)
+13
View File
@@ -188,3 +188,16 @@ def test_prepare_warns_about_a_cwa_password_without_a_user_name(monkeypatch, tmp
with caplog.at_level("WARNING"):
config.prepare()
assert ("CWA_PASSWORD is set" in caplog.text) is warns
@pytest.mark.parametrize("exists", [False, True])
def test_prepare_does_not_make_the_ingest_folder(monkeypatch, tmp_path, caplog, exists):
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
for name, folder in [("CONFIG_DIR", "config"), ("UPLOAD_DIR", "uploads"), ("TMP_DIR", "tmp"), ("INGEST_DIR", "in")]:
monkeypatch.setattr(config, name, tmp_path / folder)
monkeypatch.setattr(config, "PERSONAL_INI", tmp_path / "config" / "personal.ini")
if exists:
config.INGEST_DIR.mkdir()
config.prepare()
assert config.INGEST_DIR.exists() is exists
assert ("does not exist. Check INGEST_DIR and the volume mount" in caplog.text) is not exists
+13 -1
View File
@@ -9,7 +9,7 @@ from test_build import URL, chapters, metahtml
from bindery import fff
from bindery.cwa import BookFinder, CwaClient, CwaError, plain_title, search_text, title_rank
from bindery.epub import epub_metadata, epub_urls
from bindery.ingest import MAX_NAME_LENGTH, IngestFolder
from bindery.ingest import MAX_NAME_LENGTH, IngestError, IngestFolder
STORY_URL = "https://www.royalroad.com/fiction/1234"
@@ -187,6 +187,7 @@ def test_epub_reader(story_epub, tmp_path):
def test_ingest_uses_part_names_and_unique_names(tmp_path):
folder = IngestFolder(tmp_path / "ingest")
folder.path.mkdir()
epub = tmp_path / "x.epub"
epub.write_bytes(b"PK")
first = folder.ingest(epub, 'A: "Story" / Name')
@@ -198,6 +199,7 @@ def test_ingest_uses_part_names_and_unique_names(tmp_path):
def test_send_update_writes_sidecar(tmp_path):
folder = IngestFolder(tmp_path / "ingest")
folder.path.mkdir()
epub = tmp_path / "x.epub"
epub.write_bytes(b"PK")
path = folder.send_update(epub, 7, "T" * 300)
@@ -206,3 +208,13 @@ def test_send_update_writes_sidecar(tmp_path):
manifest = json.loads((folder.path / (path.name + ".cwa.json")).read_text())
assert manifest == {"action": "add_format", "book_id": 7}
assert path.read_bytes() == b"PK"
def test_a_missing_ingest_folder_is_an_error_and_is_not_made(tmp_path, story_epub):
source = tmp_path / "book.epub"
source.write_bytes(story_epub)
folder = IngestFolder(tmp_path / "missing" / "ingest")
for send in (lambda: folder.ingest(source, "Title"), lambda: folder.send_update(source, 5, "Title")):
with pytest.raises(IngestError, match="does not exist. Check INGEST_DIR and the volume mount."):
send()
assert not (tmp_path / "missing").exists()
+1
View File
@@ -35,6 +35,7 @@ def fake(story_epub, monkeypatch, tmp_path):
monkeypatch.setattr(config, "CWA_PASSWORD", "secret")
monkeypatch.setattr(orchestrator, "store", Store(tmp_path / "bindery.db"))
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
config.INGEST_DIR.mkdir()
monkeypatch.setattr(orchestrator, "finder", orchestrator.BookFinder())
yield server
server.close()
+7 -6
View File
@@ -21,6 +21,7 @@ from bindery.store import Store
@pytest.fixture(autouse=True)
def isolated(client, jobs, monkeypatch, tmp_path):
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
config.INGEST_DIR.mkdir()
monkeypatch.setattr(config, "TMP_DIR", tmp_path / "tmp")
config.TMP_DIR.mkdir()
monkeypatch.setattr(config, "CWA_URL", "")
@@ -151,7 +152,7 @@ def test_build_without_ingest_succeeds_and_keeps_the_book_local(monkeypatch, tmp
assert story["status"] == "ok"
assert story["ingested_at"] == ("2026-01-01" if existing else None)
assert list(config.TMP_DIR.glob(f"story-{story_id}-*.epub"))
assert not (tmp_path / "ingest").exists()
assert not list((tmp_path / "ingest").iterdir())
details = orchestrator.story_details(story_id)
assert details["uploads_paused"] is True
assert details["story"]["upload_held"] is True
@@ -184,7 +185,7 @@ def test_linking_off_exports_books_and_respects_pause(monkeypatch, tmp_path, con
assert orchestrator.store.get_setting(story_id, "upload_held") is None
else:
assert message.startswith("Not sent.")
assert not config.INGEST_DIR.exists()
assert not list(config.INGEST_DIR.iterdir())
assert orchestrator.store.get_setting(story_id, "upload_held") is True
@@ -221,7 +222,7 @@ def test_enabling_linking_restores_the_saved_approval_preference(library):
assert orchestrator.get_settings()["hold_new_links"] is True
assert orchestrator.story_details(story_id)["story"]["needs_approval"] is True
assert "Approve link" in rebuild(story_id)
assert not config.INGEST_DIR.exists()
assert not list(config.INGEST_DIR.iterdir())
def test_linking_off_exports_every_rebuild_without_an_id_or_approval():
@@ -294,7 +295,7 @@ def test_paused_unlinked_update_does_not_raise_not_found(monkeypatch):
story_id = add_story(ingested_at="2026-01-01")
assert rebuild(story_id) == "Not sent. Turn off pause to send."
assert orchestrator.store.get_story(story_id)["status"] == "ok"
assert not config.INGEST_DIR.exists()
assert not list(config.INGEST_DIR.iterdir())
def test_enabling_linking_rebuilds_held_books_and_matches_again(monkeypatch, jobs, tmp_path):
@@ -335,7 +336,7 @@ def test_upload_settings_matrix(client, link_books, hold_new_links, paused, save
assert listed["upload_held"] is pending
assert listed["approval_held"] is (link_books and hold_new_links and not paused)
assert bool(orchestrator.store.get_setting(story_id, "upload_held")) is pending
assert config.INGEST_DIR.exists() is (not pending)
assert bool(list(config.INGEST_DIR.iterdir())) is (not pending)
@pytest.mark.parametrize("missing_ingest", [False, True])
@@ -376,7 +377,7 @@ def test_resuming_uploads_does_not_bypass_approval(client, jobs, library):
assert client.get("/api/stories").json[0]["approval_held"] is True
assert "Approve link" in orchestrator._run_rebuild(jobs.get_next())
assert orchestrator.store.get_setting(story_id, "upload_held") is True
assert not config.INGEST_DIR.exists()
assert not list(config.INGEST_DIR.iterdir())
def test_missing_library_connection_is_not_an_approval_hold(client, monkeypatch):