Add CWA_ENABLED to run Bindery without CWA

With CWA_ENABLED=false, Bindery ignores the CWA settings, makes no request
to CWA, and shows nothing about CWA in the log and the web interface. A
set INGEST_DIR still gets each changed book as a plain epub.

Without it, the log names each CWA function that a missing setting turns
off.
This commit is contained in:
tim
2026-10-02 23:39:58 +02:00
parent bb6c87cdd5
commit 05ef1dbfad
16 changed files with 265 additions and 37 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ Start a development server with no CWA:
```bash ```bash
mkdir -p dev/ingest mkdir -p dev/ingest
CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery CWA_ENABLED=false CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery
``` ```
The server has no reloader. If you change Python code, restart it. Flask reads `bindery/static` from disk on each request, so a server that is not restarted sends new JavaScript to old Python routes. The server has no reloader. If you change Python code, restart it. Flask reads `bindery/static` from disk on each request, so a server that is not restarted sends new JavaScript to old Python routes.
+22 -6
View File
@@ -53,6 +53,7 @@ Environment variables set the configuration. **Restart the container after a cha
| Variable | Description | Default value | | Variable | Description | Default value |
| --- | --- | --- | | --- | --- | --- |
| `CWA_ENABLED` | `false` if you have no CWA. See "Without CWA". | `true` |
| `CWA_URL` | The address of CWA, for example `http://calibre-web-automated:8083` | None | | `CWA_URL` | The address of CWA, for example `http://calibre-web-automated:8083` | None |
| `CWA_USERNAME`, `CWA_PASSWORD` | The CWA account of Bindery. Leave them unset if CWA asks for no login. | None | | `CWA_USERNAME`, `CWA_PASSWORD` | The CWA account of Bindery. Leave them unset if CWA asks for no login. | None |
| `CWA_DB_PATH` | Calibre-Web's `app.db`, for logins. See "CWA logins". | None | | `CWA_DB_PATH` | Calibre-Web's `app.db`, for logins. See "CWA logins". | None |
@@ -72,7 +73,7 @@ Environment variables set the configuration. **Restart the container after a cha
| `SESSION_COOKIE_SECURE` | `true` if users reach Bindery only over HTTPS | `false` | | `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` | | `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 | | `URL_BASE` | A path prefix, such as `/bindery`. See "Reverse proxy". | None |
| `INGEST_DIR` | The CWA ingest folder. If unset, all uploads stay paused. Bindery does not make this folder. | None | | `INGEST_DIR` | The CWA ingest folder. If unset, all uploads stay paused. Bindery does not make this folder. Without CWA, a folder for the built ePubs. | None |
| `CONFIG_DIR` | The story database and `personal.ini` | `/config` | | `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` | | `TMP_DIR` | The folder for the built ePubs. Bindery keeps the last build of each story. | `/tmp/bindery` |
| `LOG_LEVEL` | The log level | `INFO` | | `LOG_LEVEL` | The log level | `INFO` |
@@ -81,11 +82,26 @@ Separate the values in a list with commas.
### Without CWA ### Without CWA
Leave `INGEST_DIR`, `CWA_URL` and `CALIBRE_DB_PATH` unset to run Bindery without CWA. Set `CWA_ENABLED=false` to run Bindery without CWA. Bindery still downloads chapters and builds
Bindery still downloads chapters and builds ePubs. Download the books from each story page. ePubs. Download the books from each story page.
If you use the example Compose file, remove its CWA connection values, `INGEST_DIR` and the ingest volume mount. With `CWA_ENABLED=false`:
Use `AUTH_METHOD=local` or `proxy` for logins. On a private network, you can use `AUTH_METHOD=none`.
- Bindery makes no request to CWA. It ignores `CWA_URL`, `CWA_USERNAME`, `CWA_PASSWORD`,
`CWA_DB_PATH` and `CALIBRE_DB_PATH`.
- The web interface and the log show nothing about CWA. The pause, the library links and the
approvals do not apply.
- If `INGEST_DIR` is set, Bindery puts each changed book in that folder as a plain ePub. Use this to
import the books into other software. Each file gets a new name, so Bindery never replaces a file
in the folder.
- `AUTH_METHOD=cwa` does not work. Use `local` or `proxy`. On a private network, you can use `none`.
If you use the example Compose file, remove its CWA connection values. Keep `INGEST_DIR` and its
volume mount only if you want the ePubs in a folder.
Without `CWA_ENABLED=false`, Bindery expects CWA. At each start, the log then has a warning for each
function that a missing `INGEST_DIR`, `CWA_URL` or `CALIBRE_DB_PATH` turns off. Bindery still
downloads chapters and builds ePubs.
Without `INGEST_DIR`, Settings shows "Pause all uploads" checked and a highlighted explanation. Without `INGEST_DIR`, Settings shows "Pause all uploads" checked and a highlighted explanation.
You cannot clear that checkbox until you set `INGEST_DIR` and restart Bindery. You cannot clear that checkbox until you set `INGEST_DIR` and restart Bindery.
@@ -590,7 +606,7 @@ enough to work on downloads, chapters, rules and the web interface.
```bash ```bash
mkdir -p dev/ingest mkdir -p dev/ingest
CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery CWA_ENABLED=false CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery
``` ```
Open `http://127.0.0.1:8085`. The folders go in `dev/`, which git ignores. To look at an ePub, use Open `http://127.0.0.1:8085`. The folders go in `dev/`, which git ignores. To look at an ePub, use
+2
View File
@@ -118,6 +118,8 @@ def config_error() -> str | None:
return "AUTH_METHOD=local needs BINDERY_USERNAME and BINDERY_PASSWORD_HASH" return "AUTH_METHOD=local needs BINDERY_USERNAME and BINDERY_PASSWORD_HASH"
if not _valid_local_hash(config.LOCAL_PASSWORD_HASH): if not _valid_local_hash(config.LOCAL_PASSWORD_HASH):
return "BINDERY_PASSWORD_HASH must be a Werkzeug scrypt or PBKDF2 hash of a nonempty password" return "BINDERY_PASSWORD_HASH must be a Werkzeug scrypt or PBKDF2 hash of a nonempty password"
if method == AUTH_CWA and not config.CWA_ENABLED:
return "AUTH_METHOD=cwa needs CWA, but CWA_ENABLED is false"
if method == AUTH_CWA and not (config.CWA_URL or config.CWA_DB_PATH): if method == AUTH_CWA and not (config.CWA_URL or config.CWA_DB_PATH):
return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH" return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"
if method == AUTH_PROXY: if method == AUTH_PROXY:
+31 -4
View File
@@ -64,6 +64,14 @@ CWA_PASSWORD = os.getenv("CWA_PASSWORD", "")
# Independent, optional read-only databases. HTTP remains the fallback. # Independent, optional read-only databases. HTTP remains the fallback.
CWA_DB_PATH = _optional_path("CWA_DB_PATH") # Calibre-Web app.db, for logins CWA_DB_PATH = _optional_path("CWA_DB_PATH") # Calibre-Web app.db, for logins
CALIBRE_DB_PATH = _optional_path("CALIBRE_DB_PATH") # calibre metadata.db, for book IDs CALIBRE_DB_PATH = _optional_path("CALIBRE_DB_PATH") # calibre metadata.db, for book IDs
# false if the user has no CWA. Bindery then ignores the CWA settings above, and shows nothing about CWA. INGEST_DIR
# still gets each built book, as a plain epub. Only a clear "false" turns CWA off.
CWA_ENABLED = os.getenv("CWA_ENABLED", "").strip().lower() not in ("false", "no", "0", "n", "off")
CWA_SETTINGS = ("CWA_URL", "CWA_USERNAME", "CWA_PASSWORD", "CWA_DB_PATH", "CALIBRE_DB_PATH")
IGNORED_SETTINGS = [] if CWA_ENABLED else [name for name in CWA_SETTINGS if os.getenv(name, "").strip()]
if not CWA_ENABLED:
CWA_URL = CWA_USERNAME = CWA_PASSWORD = ""
CWA_DB_PATH = CALIBRE_DB_PATH = None
UPDATE_INTERVAL_HOURS = _number("UPDATE_INTERVAL_HOURS", "24") UPDATE_INTERVAL_HOURS = _number("UPDATE_INTERVAL_HOURS", "24")
DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title") DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title")
@@ -121,10 +129,29 @@ def prepare() -> None:
if not PERSONAL_INI.exists(): if not PERSONAL_INI.exists():
shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI) shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI)
logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}") logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}")
if not CWA_ENABLED:
if IGNORED_SETTINGS:
logger.info(f"CWA_ENABLED is false. Bindery ignores {', '.join(IGNORED_SETTINGS)}.")
return
if CWA_PASSWORD and not CWA_USERNAME: if CWA_PASSWORD and not CWA_USERNAME:
logger.warning("CWA_PASSWORD is set, but CWA_USERNAME is not. Bindery sends no login to CWA.") logger.warning("CWA_PASSWORD is set, but CWA_USERNAME is not. Bindery sends no login to CWA.")
if INGEST_DIR and not (CWA_URL or CALIBRE_DB_PATH): missing = _missing_cwa()
logger.warning( for warning in missing:
"INGEST_DIR is set, but CWA_URL and CALIBRE_DB_PATH are not. Bindery cannot find the library book " logger.warning(warning)
"of a story, so each update adds a new book to CWA." if missing:
logger.warning("If you do not use CWA, set CWA_ENABLED=false. Bindery then shows no messages about CWA.")
def _missing_cwa() -> list[str]:
"""What Bindery cannot do with CWA, because a setting is not set."""
missing = []
if INGEST_DIR is None:
missing.append("INGEST_DIR is not set. Bindery sends no book to CWA.")
if not (CWA_URL or CALIBRE_DB_PATH):
effect = ", so each update adds a new book to CWA." if INGEST_DIR else "."
missing.append(
"CWA_URL and CALIBRE_DB_PATH are not set. Bindery cannot find the library book of a story" + effect
) )
elif not CWA_URL:
missing.append("CWA_URL is not set. Bindery cannot search the CWA library, or import books from it.")
return missing
+21 -7
View File
@@ -880,10 +880,12 @@ def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, tit
message = f"Updated library book {book['id']}" message = f"Updated library book {book['id']}"
else: else:
ingest_folder.ingest(epub, f"{title} - {author}" if author else title) ingest_folder.ingest(epub, f"{title} - {author}" if author else title)
message = "Sent to CWA" if _link_books() else "Sent to ingest folder"
# Without CWA, the ingest folder is a plain folder, so the story did not go to CWA.
if config.CWA_ENABLED:
if not book:
store.update_story(story["id"], ingested_at=now()) store.update_story(story["id"], ingested_at=now())
story["ingested_at"] = now() story["ingested_at"] = now()
message = "Sent to CWA" if _link_books() else "Sent to ingest folder"
store.set_setting(story["id"], "upload_held", None)
store.set_setting(story["id"], "unapproved_link", None) store.set_setting(story["id"], "unapproved_link", None)
return message return message
@@ -896,19 +898,31 @@ def _send(
A held book that did not change is not built again. With `force`, Bindery builds the book in each case. A held book that did not change is not built again. With `force`, Bindery builds the book in each case.
""" """
story_id = story["id"] story_id = story["id"]
book = _linked_book(story)
held = _hold_reason(story, book)
changed = key != store.get_setting(story_id, "built_key") changed = key != store.get_setting(story_id, "built_key")
# Without CWA, a story has no library book, and nothing holds a book.
book = _linked_book(story) if config.CWA_ENABLED else None
held = _hold_reason(story, book) if config.CWA_ENABLED else None
if held: if held:
if changed or force: if changed or force:
_built_epub(story, overrides, key, force) _built_epub(story, overrides, key, force)
store.set_setting(story_id, "upload_held", True) store.set_setting(story_id, "upload_held", True)
return held, changed return held, changed
message = _deliver(story, _built_epub(story, overrides, key, force), book, title, author) epub = _built_epub(story, overrides, key, force)
if config.INGEST_DIR is None:
message = "Built the book"
else:
message = _deliver(story, epub, book, title, author)
store.set_setting(story_id, "sent_key", key) store.set_setting(story_id, "sent_key", key)
store.set_setting(story_id, "upload_held", None)
return message, changed return message, changed
def _done_key(story_id: int) -> Any:
"""The key of the book that is at its destination: CWA, the ingest folder or, without both, the built file."""
plain = not config.CWA_ENABLED and config.INGEST_DIR is None
return store.get_setting(story_id, "built_key" if plain else "sent_key")
def adopt_build_keys() -> None: def adopt_build_keys() -> None:
"""Give the stories of an older Bindery their keys, so the first check does not send each book again.""" """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") built = store.story_settings("built_key")
@@ -1057,7 +1071,7 @@ def _run_rebuild(job: Job) -> str:
overrides = _epub_overrides(story, pinned) overrides = _epub_overrides(story, pinned)
key = _build_key(story, overrides, story["site_values"]) key = _build_key(story, overrides, story["site_values"])
force = bool(job.options.get("force")) force = bool(job.options.get("force"))
if not force and key == store.get_setting(story["id"], "sent_key"): if not force and key == _done_key(story["id"]):
return "No changes" return "No changes"
message, changed = _send(story, overrides, key, story["title"], story["author"], force) message, changed = _send(story, overrides, key, story["title"], story["author"], force)
changes: dict[str, Any] = {"last_changed": now()} if changed or force else {} changes: dict[str, Any] = {"last_changed": now()} if changed or force else {}
@@ -1300,7 +1314,7 @@ def _run_site_job(job: Job) -> str:
overrides = _epub_overrides(story, pinned) overrides = _epub_overrides(story, pinned)
key = _build_key(story, overrides, snap.calibre) key = _build_key(story, overrides, snap.calibre)
checked: dict[str, Any] = {"pinned": pinned, "site_values": snap.calibre, "title": title, "last_checked": now()} checked: dict[str, Any] = {"pinned": pinned, "site_values": snap.calibre, "title": title, "last_checked": now()}
if key == store.get_setting(story_id, "sent_key"): if key == _done_key(story_id):
store.update_story(story_id, **checked, status="ok", message="No changes") store.update_story(story_id, **checked, status="ok", message="No changes")
return "No changes. " + ", ".join(summary) return "No changes. " + ", ".join(summary)
+1 -1
View File
@@ -102,7 +102,7 @@ export function settingsPage(main, _params, config) {
}, { class: 'primary' }), }, { class: 'primary' }),
), ),
), ),
h( config.cwa_enabled && h(
'section', 'section',
{ class: 'card' }, { class: 'card' },
h('h2', {}, 'Uploads to CWA'), h('h2', {}, 'Uploads to CWA'),
+2 -1
View File
@@ -341,7 +341,7 @@ export function storiesPage(main, _params, config) {
'div', 'div',
{ class: 'controls' }, { class: 'controls' },
add, add,
h( config.cwa_enabled && h(
'span', 'span',
{ class: 'hint' }, { class: 'hint' },
'If CWA already has this story, Bindery links that book and keeps its chapters, then downloads only the chapters that are missing.', 'If CWA already has this story, Bindery links that book and keeps its chapters, then downloads only the chapters that are missing.',
@@ -371,6 +371,7 @@ export function storiesPage(main, _params, config) {
), ),
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Jobs')), jobs.el), h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Jobs')), jobs.el),
epubImport(), epubImport(),
config.cwa_enabled &&
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)), h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)),
); );
+12 -6
View File
@@ -69,6 +69,9 @@ const table = (headings, rows, extra = '') =>
export function storyPage(main, { storyId }, config) { export function storyPage(main, { storyId }, config) {
const id = Number(storyId); const id = Number(storyId);
const fields = config.metadata_fields; const fields = config.metadata_fields;
// With CWA_ENABLED=false, the page shows nothing about CWA.
const cwa = config.cwa_enabled;
const libraryStays = cwa ? ' The library book stays.' : '';
let details = null; let details = null;
let selected = new Set(); let selected = new Set();
let mode = 'new'; let mode = 'new';
@@ -147,7 +150,7 @@ export function storyPage(main, { storyId }, config) {
{ class: 'byline' }, { class: 'byline' },
`${story.author} · `, `${story.author} · `,
externalLink(story.url, story.site), externalLink(story.url, story.site),
` · ${library}`, cwa && ` · ${library}`,
), ),
h( h(
'p', 'p',
@@ -184,6 +187,7 @@ export function storyPage(main, { storyId }, config) {
// Why the book did not go to CWA, and the approval of a new library link. // Why the book did not go to CWA, and the approval of a new library link.
function holdNotice() { function holdNotice() {
if (!cwa) return null;
const { story, book } = details; const { story, book } = details;
const linking = details.link_books !== false; const linking = details.link_books !== false;
const unlinked = !linking && story.calibre_id == null; const unlinked = !linking && story.calibre_id == null;
@@ -663,8 +667,10 @@ export function storyPage(main, { storyId }, config) {
{ class: 'hint' }, { class: 'hint' },
'Bindery writes this metadata into the ePub. You can edit each field. A locked field keeps its value on ' + 'Bindery writes this metadata into the ePub. You can edit each field. A locked field keeps its value on ' +
'update, unless you take it from the site above. An unlocked field keeps an edited value until the site ' + 'update, unless you take it from the site above. An unlocked field keeps an edited value until the site ' +
"changes that field. CWA reads the ePub's metadata only when it first imports a book, so later changes " + 'changes that field.',
'show in the ePub but not in the CWA catalog.', cwa &&
" CWA reads the ePub's metadata only when it first imports a book, so later changes show in the ePub " +
'but not in the CWA catalog.',
), ),
metadata, metadata,
), ),
@@ -706,7 +712,7 @@ export function storyPage(main, { storyId }, config) {
), ),
epubImport(id, 'Import chapters from an ePub'), epubImport(id, 'Import chapters from an ePub'),
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el), h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el),
h( cwa && h(
'details', 'details',
{ class: 'card' }, { class: 'card' },
h('summary', {}, h('h2', {}, 'Library link')), h('summary', {}, h('h2', {}, 'Library link')),
@@ -738,11 +744,11 @@ export function storyPage(main, { storyId }, config) {
'details', 'details',
{ class: 'card card-danger' }, { class: 'card card-danger' },
h('summary', {}, h('h2', {}, 'Stop tracking')), h('summary', {}, h('h2', {}, 'Stop tracking')),
h('p', { class: 'hint' }, 'Deletes the stored chapters, revisions and rules for this story. The library book stays.'), h('p', { class: 'hint' }, `Deletes the stored chapters, revisions and rules for this story.${libraryStays}`),
button( button(
'Stop tracking this story', 'Stop tracking this story',
async () => { async () => {
const message = 'Stop tracking this story? Stored chapters, revisions and rules are deleted. The library book stays.'; const message = `Stop tracking this story? Stored chapters, revisions and rules are deleted.${libraryStays}`;
if (!window.confirm(message)) return; if (!window.confirm(message)) return;
await api(`stories/${id}`, 'DELETE'); await api(`stories/${id}`, 'DELETE');
window.location.hash = '#/'; window.location.hash = '#/';
+1
View File
@@ -267,6 +267,7 @@ def app_config() -> Any:
"metadata_fields": METADATA_FIELDS, "metadata_fields": METADATA_FIELDS,
"mode_labels": MODE_LABELS, "mode_labels": MODE_LABELS,
"library_available": orchestrator.library_available(), "library_available": orchestrator.library_available(),
"cwa_enabled": config.CWA_ENABLED,
"update_interval": config.UPDATE_INTERVAL_HOURS, "update_interval": config.UPDATE_INTERVAL_HOURS,
"job_list_max_hours": config.JOB_LIST_MAX_HOURS, "job_list_max_hours": config.JOB_LIST_MAX_HOURS,
"job_list_max_count": config.JOB_LIST_MAX_COUNT, "job_list_max_count": config.JOB_LIST_MAX_COUNT,
+2
View File
@@ -5,6 +5,8 @@ services:
# The user and group that own ./config and can write CWA's ingest folder. Usually the same as CWA's. # The user and group that own ./config and can write CWA's ingest folder. Usually the same as CWA's.
user: "1000:1000" user: "1000:1000"
environment: environment:
# Without CWA: set this, and remove the CWA values below. INGEST_DIR then gets the built ePubs.
# CWA_ENABLED: "false"
# Omit this and its volume mount to keep uploads paused. # Omit this and its volume mount to keep uploads paused.
INGEST_DIR: /cwa-book-ingest INGEST_DIR: /cwa-book-ingest
# The address of CWA, as the Bindery container reaches it, and the CWA account that Bindery uses. # The address of CWA, as the Bindery container reaches it, and the CWA account that Bindery uses.
+23 -1
View File
@@ -14,7 +14,7 @@ const { setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js'
let api, main, details, cleanups; let api, main, details, cleanups;
const config = { const config = {
metadata_fields: { title: 'Title', authors: 'Authors', comments: 'Description', series: 'Series', pubdate: 'Published', tags: 'Tags', languages: 'Language' }, metadata_fields: { title: 'Title', authors: 'Authors', comments: 'Description', series: 'Series', pubdate: 'Published', tags: 'Tags', languages: 'Language' },
mode_labels: { new: 'New chapters' }, library_available: true, update_interval: 2, mode_labels: { new: 'New chapters' }, library_available: true, cwa_enabled: true, update_interval: 2,
job_list_max_hours: 720, job_list_max_count: 500, job_list_max_hours: 720, job_list_max_count: 500,
}; };
const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: '', enabled: true }; const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: '', enabled: true };
@@ -641,3 +641,25 @@ test('chapter titles show the entities that FanFicFare keeps as text', async ()
render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A & B' }] } }]); render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A & B' }] } }]);
assert.ok(find(el, 'a', 'A & B')); assert.ok(find(el, 'a', 'A & B'));
}); });
test('with CWA off, the pages show nothing about CWA', async () => {
await mountStory();
assert.ok(section(main, 'Library link'));
const off = { ...config, cwa_enabled: false, library_available: false };
// Values that show a notice with CWA on.
Object.assign(details, { uploads_paused: true, ingest_available: false, link_books: false });
details.story.upload_held = true;
await mountStory(off);
assert.ok(section(main, 'Metadata'));
assert.equal(section(main, 'Library link'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
await mountStories(off);
assert.ok(section(main, 'Add stories'));
assert.equal(section(main, 'Import from the library'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
settingsPage(main, {}, off);
await settle();
assert.ok(section(main, 'Jobs list'));
assert.equal(section(main, 'Uploads to CWA'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
});
+1 -1
View File
@@ -46,7 +46,7 @@ const text = (node) => node instanceof Element ? node.children.map(text).join(''
const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label); const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label);
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
const config = { const config = {
metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, update_interval: 0, metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, cwa_enabled: true, update_interval: 0,
job_list_max_hours: 720, job_list_max_count: 500, job_list_max_hours: 720, job_list_max_count: 500,
}; };
const story = { const story = {
+2 -2
View File
@@ -7,7 +7,7 @@ const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storyPage } = await import('../../bindery/static/js/story.js'); const { storyPage } = await import('../../bindery/static/js/story.js');
const { storiesPage } = await import('../../bindery/static/js/stories.js'); const { storiesPage } = await import('../../bindery/static/js/stories.js');
const config = { job_list_max_hours: 720, job_list_max_count: 500 }; const config = { cwa_enabled: true, job_list_max_hours: 720, job_list_max_count: 500 };
const book = { id: 7, uuid: 'book-7', title: 'Library Title', authors: ['Writer'] }; const book = { id: 7, uuid: 'book-7', title: 'Library Title', authors: ['Writer'] };
const details = (story, extra = {}) => ({ const details = (story, extra = {}) => ({
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com', story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
@@ -27,7 +27,7 @@ async function renderStory(t, data) {
backend.on('GET', 'jobs?story_id=1', []); backend.on('GET', 'jobs?story_id=1', []);
backend.on('GET', 'jobs', []); backend.on('GET', 'jobs', []);
const main = new Element('main'); const main = new Element('main');
const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} }); const cleanup = storyPage(main, { storyId: '1' }, { cwa_enabled: true, metadata_fields: {}, mode_labels: {} });
t.after(() => cleanup?.()); t.after(() => cleanup?.());
await settle(); await settle();
const summary = nodes(main).find((el) => el.className === 'card story-summary'); const summary = nodes(main).find((el) => el.className === 'card story-summary');
+1
View File
@@ -46,6 +46,7 @@ def test_add_list_download_and_cancel_jobs(client, story):
assert result.json["mode"] == "metadata" assert result.json["mode"] == "metadata"
app_config = client.get("/api/config").json app_config = client.get("/api/config").json
assert app_config["metadata_fields"]["title"] assert app_config["metadata_fields"]["title"]
assert app_config["cwa_enabled"] is True
assert (app_config["story_jobs_hours"], app_config["story_jobs_count"]) == (36, 10) assert (app_config["story_jobs_hours"], app_config["story_jobs_count"]) == (36, 10)
assert (app_config["main_jobs_hours"], app_config["main_jobs_count"]) == (6, 25) assert (app_config["main_jobs_hours"], app_config["main_jobs_count"]) == (6, 25)
assert (app_config["job_list_max_hours"], app_config["job_list_max_count"]) == (720, 500) assert (app_config["job_list_max_hours"], app_config["job_list_max_count"]) == (720, 500)
+72
View File
@@ -104,6 +104,7 @@ def test_defaults(monkeypatch, reload_config):
"PROXY_AUTH_TRUSTED_IPS", "PROXY_AUTH_TRUSTED_IPS",
"ALLOWED_HOSTS", "ALLOWED_HOSTS",
"BINDERY_ADMIN_USERS", "BINDERY_ADMIN_USERS",
"CWA_ENABLED",
"SESSION_COOKIE_SECURE", "SESSION_COOKIE_SECURE",
"TRUST_PROXY", "TRUST_PROXY",
"CWA_DB_PATH", "CWA_DB_PATH",
@@ -131,6 +132,8 @@ def test_defaults(monkeypatch, reload_config):
] ]
assert config.ALLOWED_HOSTS == [] assert config.ALLOWED_HOSTS == []
assert config.ADMIN_USERS == [] assert config.ADMIN_USERS == []
assert config.CWA_ENABLED is True
assert config.IGNORED_SETTINGS == []
assert config.SESSION_COOKIE_SECURE is False assert config.SESSION_COOKIE_SECURE is False
assert config.TRUST_PROXY is False assert config.TRUST_PROXY is False
assert config.CWA_DB_PATH is None assert config.CWA_DB_PATH is None
@@ -147,7 +150,76 @@ def test_empty_ingest_directory_disables_uploads(monkeypatch, reload_config, val
assert config.INGEST_DIR is None assert config.INGEST_DIR is None
@pytest.mark.parametrize("value", ["false", " No ", "0", "off"])
def test_cwa_off_ignores_the_cwa_settings_but_not_the_ingest_folder(monkeypatch, reload_config, value):
monkeypatch.setenv("CWA_ENABLED", value)
monkeypatch.setenv("CWA_URL", "https://cwa.invalid")
monkeypatch.setenv("CWA_USERNAME", "bindery")
monkeypatch.setenv("CWA_PASSWORD", "secret")
monkeypatch.setenv("CWA_DB_PATH", "/cwa/app.db")
monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db")
monkeypatch.setenv("INGEST_DIR", "/books")
reload_config()
assert config.CWA_ENABLED is False
assert (config.CWA_URL, config.CWA_USERNAME, config.CWA_PASSWORD) == ("", "", "")
assert (config.CWA_DB_PATH, config.CALIBRE_DB_PATH) == (None, None)
assert config.IGNORED_SETTINGS == ["CWA_URL", "CWA_USERNAME", "CWA_PASSWORD", "CWA_DB_PATH", "CALIBRE_DB_PATH"]
assert str(config.INGEST_DIR) == "/books"
@pytest.mark.parametrize("value", ["", "true", "maybe"])
def test_only_a_clear_false_turns_cwa_off(monkeypatch, reload_config, value):
monkeypatch.setenv("CWA_ENABLED", value)
monkeypatch.setenv("CWA_URL", "https://cwa.invalid")
reload_config()
assert config.CWA_ENABLED is True
assert config.CWA_URL == "https://cwa.invalid"
@pytest.mark.parametrize("ignored", [[], ["CWA_URL", "CALIBRE_DB_PATH"]])
def test_prepare_with_cwa_off_logs_nothing_about_missing_cwa(monkeypatch, tmp_path, caplog, ignored):
monkeypatch.setattr(config, "CWA_ENABLED", False)
monkeypatch.setattr(config, "IGNORED_SETTINGS", ignored)
monkeypatch.setattr(config, "INGEST_DIR", None)
monkeypatch.setattr(config, "CWA_URL", "")
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
for name, folder in [("CONFIG_DIR", "config"), ("UPLOAD_DIR", "uploads"), ("TMP_DIR", "tmp")]:
monkeypatch.setattr(config, name, tmp_path / folder)
monkeypatch.setattr(config, "PERSONAL_INI", tmp_path / "config" / "personal.ini")
with caplog.at_level("INFO"):
config.prepare()
lines = [r.getMessage() for r in caplog.records]
expected = ["CWA_ENABLED is false. Bindery ignores CWA_URL, CALIBRE_DB_PATH."] if ignored else []
assert [line for line in lines if "CWA" in line or "INGEST" in line] == expected
@pytest.mark.parametrize(
("ingest", "url", "database", "expected"),
[
(False, "", None, ["INGEST_DIR is not set", "cannot find the library book of a story.", "CWA_ENABLED=false"]),
(True, "", None, ["so each update adds a new book to CWA.", "CWA_ENABLED=false"]),
(True, "", Path("/library/metadata.db"), ["CWA_URL is not set. Bindery cannot search", "CWA_ENABLED=false"]),
(False, "https://cwa.invalid", None, ["INGEST_DIR is not set", "CWA_ENABLED=false"]),
(True, "https://cwa.invalid", None, []),
],
)
def test_prepare_warns_about_each_missing_cwa_function(monkeypatch, tmp_path, caplog, ingest, url, database, expected):
monkeypatch.setattr(config, "CWA_URL", url)
monkeypatch.setattr(config, "CALIBRE_DB_PATH", database)
monkeypatch.setattr(config, "INGEST_DIR", tmp_path if ingest else None)
for name, folder in [("CONFIG_DIR", "config"), ("UPLOAD_DIR", "uploads"), ("TMP_DIR", "tmp")]:
monkeypatch.setattr(config, name, tmp_path / folder)
monkeypatch.setattr(config, "PERSONAL_INI", tmp_path / "config" / "personal.ini")
with caplog.at_level("WARNING"):
config.prepare()
warnings = [r.getMessage() for r in caplog.records]
assert len(warnings) == len(expected)
for warning, text in zip(warnings, expected, strict=True):
assert text in warning
def test_prepare_without_cwa_creates_only_bindery_folders(monkeypatch, tmp_path, caplog): def test_prepare_without_cwa_creates_only_bindery_folders(monkeypatch, tmp_path, caplog):
monkeypatch.setattr(config, "CWA_ENABLED", False)
monkeypatch.setattr(config, "INGEST_DIR", None) monkeypatch.setattr(config, "INGEST_DIR", None)
monkeypatch.setattr(config, "CWA_URL", "") monkeypatch.setattr(config, "CWA_URL", "")
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None) monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
+67 -3
View File
@@ -9,11 +9,12 @@ from unittest.mock import Mock
import pytest import pytest
from test_api import H from test_api import H
from test_api import client as client from test_api import client as client
from test_deliver import STORY_URL, add_story, rebuild, site from test_deliver import STORY_URL, add_story, rebuild, site, update
from test_deliver import jobs as jobs from test_deliver import jobs as jobs
from bindery import config, orchestrator from bindery import config, orchestrator
from bindery.cwa import Book from bindery.cwa import Book
from bindery.ingest import IngestError
from bindery.jobs import DownloadMode, Job from bindery.jobs import DownloadMode, Job
from bindery.store import Store from bindery.store import Store
@@ -39,7 +40,8 @@ def library(isolated, monkeypatch):
monkeypatch.setattr(orchestrator, "_find_book", Mock(return_value=None)) monkeypatch.setattr(orchestrator, "_find_book", Mock(return_value=None))
def test_app_starts_without_any_cwa_configuration(tmp_path): @pytest.mark.parametrize("enabled", [False, True])
def test_app_starts_without_any_cwa_configuration(tmp_path, enabled):
env = { env = {
**os.environ, **os.environ,
"CONFIG_DIR": str(tmp_path / "standalone"), "CONFIG_DIR": str(tmp_path / "standalone"),
@@ -47,6 +49,7 @@ def test_app_starts_without_any_cwa_configuration(tmp_path):
"AUTH_METHOD": "none", "AUTH_METHOD": "none",
"BINDERY_START_THREADS": "false", "BINDERY_START_THREADS": "false",
"URL_BASE": "", "URL_BASE": "",
"CWA_ENABLED": str(enabled),
} }
for name in ("INGEST_DIR", "CWA_URL", "CWA_DB_PATH", "CALIBRE_DB_PATH"): for name in ("INGEST_DIR", "CWA_URL", "CWA_DB_PATH", "CALIBRE_DB_PATH"):
env.pop(name, None) env.pop(name, None)
@@ -69,8 +72,69 @@ def test_app_starts_without_any_cwa_configuration(tmp_path):
timeout=30, timeout=30,
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert "WARNING" not in result.stderr
assert "ERROR" not in result.stderr assert "ERROR" not in result.stderr
# Bindery names what is missing, until the user says that there is no CWA.
for text in ("WARNING", "INGEST_DIR is not set", "CWA_URL and CALIBRE_DB_PATH are not set", "CWA_ENABLED=false"):
assert (text in result.stderr) is enabled
if not enabled:
assert "CWA" not in result.stderr and "INGEST" not in result.stderr
@pytest.fixture
def cwa_off(monkeypatch):
monkeypatch.setattr(config, "CWA_ENABLED", False)
def test_without_cwa_and_an_ingest_folder_a_job_only_builds_a_changed_book(monkeypatch, cwa_off):
monkeypatch.setattr(config, "INGEST_DIR", None)
site(monkeypatch, [(1, "One", "First chapter")])
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
assert orchestrator._run_site_job(job).endswith("Built the book")
story_id = job.story_id
story = orchestrator.store.get_story(story_id)
assert (story["status"], story["message"], story["ingested_at"]) == ("ok", "Built the book", None)
assert orchestrator.store.get_setting(story_id, "upload_held") is None
assert len(list(config.TMP_DIR.glob(f"story-{story_id}-*.epub"))) == 1
assert update(story_id).startswith("No changes")
assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes"
assert rebuild(story_id) == "Built the book"
site(monkeypatch, [(1, "One", "First chapter"), (2, "Two", "Second chapter")])
assert update(story_id).endswith("Built the book")
orchestrator.cwa.assert_not_called()
@pytest.mark.parametrize("saved_id", [None, 7])
def test_without_cwa_the_ingest_folder_gets_each_changed_book_as_a_plain_epub(cwa_off, saved_id):
# A pause, a hold and a saved book ID are from a time with CWA. They do not apply.
orchestrator.save_settings(pause_uploads=True, hold_new_links=True)
story_id = add_story(calibre_id=saved_id, calibre_uuid="saved-uuid" if saved_id else None)
orchestrator._link_changed(story_id)
orchestrator.store.set_setting(story_id, "upload_held", True)
assert rebuild(story_id) == "Sent to ingest folder"
assert [p.suffix for p in config.INGEST_DIR.iterdir()] == [".epub"]
assert orchestrator.store.get_story(story_id)["ingested_at"] is None
assert orchestrator.store.get_setting(story_id, "upload_held") is None
assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes"
assert len(list(config.INGEST_DIR.iterdir())) == 1
orchestrator.cwa.assert_not_called()
def test_without_cwa_a_missing_ingest_folder_is_still_an_error(monkeypatch, tmp_path, cwa_off):
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "missing")
story_id = add_story()
with pytest.raises(IngestError, match="does not exist"):
rebuild(story_id)
# The book counts as sent only when it is in the folder.
assert orchestrator.store.get_setting(story_id, "sent_key") is None
def test_cwa_off_shows_in_the_config_and_refuses_cwa_logins(client, monkeypatch, cwa_off):
assert client.get("/api/config").json["cwa_enabled"] is False
monkeypatch.setattr(config, "AUTH_METHOD", "cwa")
monkeypatch.setattr(config, "CWA_DB_PATH", "/cwa/app.db")
response = client.get("/api/stories")
assert response.status_code == 503
assert response.json["error"].startswith("AUTH_METHOD=cwa needs CWA, but CWA_ENABLED is false.")
def test_link_setting_defaults_on_and_persists(client, monkeypatch, tmp_path, library): def test_link_setting_defaults_on_and_persists(client, monkeypatch, tmp_path, library):