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:
@@ -18,7 +18,7 @@ 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
|
||||
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.
|
||||
|
||||
@@ -53,6 +53,7 @@ Environment variables set the configuration. **Restart the container after a cha
|
||||
|
||||
| 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_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 |
|
||||
@@ -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` |
|
||||
| `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. 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` |
|
||||
| `TMP_DIR` | The folder for the built ePubs. Bindery keeps the last build of each story. | `/tmp/bindery` |
|
||||
| `LOG_LEVEL` | The log level | `INFO` |
|
||||
@@ -81,11 +82,26 @@ Separate the values in a list with commas.
|
||||
|
||||
### Without CWA
|
||||
|
||||
Leave `INGEST_DIR`, `CWA_URL` and `CALIBRE_DB_PATH` unset to run Bindery without CWA.
|
||||
Bindery still downloads chapters and builds ePubs. Download the books from each story page.
|
||||
Set `CWA_ENABLED=false` to run Bindery without CWA. Bindery still downloads chapters and builds
|
||||
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.
|
||||
Use `AUTH_METHOD=local` or `proxy` for logins. On a private network, you can use `AUTH_METHOD=none`.
|
||||
With `CWA_ENABLED=false`:
|
||||
|
||||
- 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.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -118,6 +118,8 @@ def config_error() -> str | None:
|
||||
return "AUTH_METHOD=local needs BINDERY_USERNAME and BINDERY_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"
|
||||
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):
|
||||
return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"
|
||||
if method == AUTH_PROXY:
|
||||
|
||||
+31
-4
@@ -64,6 +64,14 @@ CWA_PASSWORD = os.getenv("CWA_PASSWORD", "")
|
||||
# Independent, optional read-only databases. HTTP remains the fallback.
|
||||
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
|
||||
# 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")
|
||||
DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title")
|
||||
@@ -121,10 +129,29 @@ def prepare() -> None:
|
||||
if not PERSONAL_INI.exists():
|
||||
shutil.copy(DEFAULT_PERSONAL_INI, 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:
|
||||
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):
|
||||
logger.warning(
|
||||
"INGEST_DIR is set, but CWA_URL and CALIBRE_DB_PATH are not. Bindery cannot find the library book "
|
||||
"of a story, so each update adds a new book to CWA."
|
||||
missing = _missing_cwa()
|
||||
for warning in missing:
|
||||
logger.warning(warning)
|
||||
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
@@ -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']}"
|
||||
else:
|
||||
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())
|
||||
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)
|
||||
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.
|
||||
"""
|
||||
story_id = story["id"]
|
||||
book = _linked_book(story)
|
||||
held = _hold_reason(story, book)
|
||||
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 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)
|
||||
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, "upload_held", None)
|
||||
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:
|
||||
"""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")
|
||||
@@ -1057,7 +1071,7 @@ def _run_rebuild(job: Job) -> str:
|
||||
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"):
|
||||
if not force and key == _done_key(story["id"]):
|
||||
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 {}
|
||||
@@ -1300,7 +1314,7 @@ def _run_site_job(job: Job) -> str:
|
||||
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"):
|
||||
if key == _done_key(story_id):
|
||||
store.update_story(story_id, **checked, status="ok", message="No changes")
|
||||
return "No changes. " + ", ".join(summary)
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export function settingsPage(main, _params, config) {
|
||||
}, { class: 'primary' }),
|
||||
),
|
||||
),
|
||||
h(
|
||||
config.cwa_enabled && h(
|
||||
'section',
|
||||
{ class: 'card' },
|
||||
h('h2', {}, 'Uploads to CWA'),
|
||||
|
||||
@@ -341,7 +341,7 @@ export function storiesPage(main, _params, config) {
|
||||
'div',
|
||||
{ class: 'controls' },
|
||||
add,
|
||||
h(
|
||||
config.cwa_enabled && h(
|
||||
'span',
|
||||
{ class: 'hint' },
|
||||
'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),
|
||||
epubImport(),
|
||||
config.cwa_enabled &&
|
||||
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)),
|
||||
);
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ const table = (headings, rows, extra = '') =>
|
||||
export function storyPage(main, { storyId }, config) {
|
||||
const id = Number(storyId);
|
||||
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 selected = new Set();
|
||||
let mode = 'new';
|
||||
@@ -147,7 +150,7 @@ export function storyPage(main, { storyId }, config) {
|
||||
{ class: 'byline' },
|
||||
`${story.author} · `,
|
||||
externalLink(story.url, story.site),
|
||||
` · ${library}`,
|
||||
cwa && ` · ${library}`,
|
||||
),
|
||||
h(
|
||||
'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.
|
||||
function holdNotice() {
|
||||
if (!cwa) return null;
|
||||
const { story, book } = details;
|
||||
const linking = details.link_books !== false;
|
||||
const unlinked = !linking && story.calibre_id == null;
|
||||
@@ -663,8 +667,10 @@ export function storyPage(main, { storyId }, config) {
|
||||
{ class: 'hint' },
|
||||
'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 ' +
|
||||
"changes that field. 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.',
|
||||
'changes that field.',
|
||||
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,
|
||||
),
|
||||
@@ -706,7 +712,7 @@ export function storyPage(main, { storyId }, config) {
|
||||
),
|
||||
epubImport(id, 'Import chapters from an ePub'),
|
||||
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el),
|
||||
h(
|
||||
cwa && h(
|
||||
'details',
|
||||
{ class: 'card' },
|
||||
h('summary', {}, h('h2', {}, 'Library link')),
|
||||
@@ -738,11 +744,11 @@ export function storyPage(main, { storyId }, config) {
|
||||
'details',
|
||||
{ class: 'card card-danger' },
|
||||
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(
|
||||
'Stop tracking this story',
|
||||
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;
|
||||
await api(`stories/${id}`, 'DELETE');
|
||||
window.location.hash = '#/';
|
||||
|
||||
@@ -267,6 +267,7 @@ def app_config() -> Any:
|
||||
"metadata_fields": METADATA_FIELDS,
|
||||
"mode_labels": MODE_LABELS,
|
||||
"library_available": orchestrator.library_available(),
|
||||
"cwa_enabled": config.CWA_ENABLED,
|
||||
"update_interval": config.UPDATE_INTERVAL_HOURS,
|
||||
"job_list_max_hours": config.JOB_LIST_MAX_HOURS,
|
||||
"job_list_max_count": config.JOB_LIST_MAX_COUNT,
|
||||
|
||||
@@ -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.
|
||||
user: "1000:1000"
|
||||
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.
|
||||
INGEST_DIR: /cwa-book-ingest
|
||||
# The address of CWA, as the Bindery container reaches it, and the CWA account that Bindery uses.
|
||||
|
||||
+23
-1
@@ -14,7 +14,7 @@ const { setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js'
|
||||
let api, main, details, cleanups;
|
||||
const config = {
|
||||
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,
|
||||
};
|
||||
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' }] } }]);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
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,
|
||||
};
|
||||
const story = {
|
||||
|
||||
@@ -7,7 +7,7 @@ const { settingsPage } = await import('../../bindery/static/js/settings.js');
|
||||
const { storyPage } = await import('../../bindery/static/js/story.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 details = (story, extra = {}) => ({
|
||||
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', []);
|
||||
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?.());
|
||||
await settle();
|
||||
const summary = nodes(main).find((el) => el.className === 'card story-summary');
|
||||
|
||||
@@ -46,6 +46,7 @@ def test_add_list_download_and_cancel_jobs(client, story):
|
||||
assert result.json["mode"] == "metadata"
|
||||
app_config = client.get("/api/config").json
|
||||
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["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)
|
||||
|
||||
@@ -104,6 +104,7 @@ def test_defaults(monkeypatch, reload_config):
|
||||
"PROXY_AUTH_TRUSTED_IPS",
|
||||
"ALLOWED_HOSTS",
|
||||
"BINDERY_ADMIN_USERS",
|
||||
"CWA_ENABLED",
|
||||
"SESSION_COOKIE_SECURE",
|
||||
"TRUST_PROXY",
|
||||
"CWA_DB_PATH",
|
||||
@@ -131,6 +132,8 @@ def test_defaults(monkeypatch, reload_config):
|
||||
]
|
||||
assert config.ALLOWED_HOSTS == []
|
||||
assert config.ADMIN_USERS == []
|
||||
assert config.CWA_ENABLED is True
|
||||
assert config.IGNORED_SETTINGS == []
|
||||
assert config.SESSION_COOKIE_SECURE is False
|
||||
assert config.TRUST_PROXY is False
|
||||
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
|
||||
|
||||
|
||||
@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):
|
||||
monkeypatch.setattr(config, "CWA_ENABLED", False)
|
||||
monkeypatch.setattr(config, "INGEST_DIR", None)
|
||||
monkeypatch.setattr(config, "CWA_URL", "")
|
||||
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
|
||||
|
||||
@@ -9,11 +9,12 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
from test_api import H
|
||||
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 bindery import config, orchestrator
|
||||
from bindery.cwa import Book
|
||||
from bindery.ingest import IngestError
|
||||
from bindery.jobs import DownloadMode, Job
|
||||
from bindery.store import Store
|
||||
|
||||
@@ -39,7 +40,8 @@ def library(isolated, monkeypatch):
|
||||
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 = {
|
||||
**os.environ,
|
||||
"CONFIG_DIR": str(tmp_path / "standalone"),
|
||||
@@ -47,6 +49,7 @@ def test_app_starts_without_any_cwa_configuration(tmp_path):
|
||||
"AUTH_METHOD": "none",
|
||||
"BINDERY_START_THREADS": "false",
|
||||
"URL_BASE": "",
|
||||
"CWA_ENABLED": str(enabled),
|
||||
}
|
||||
for name in ("INGEST_DIR", "CWA_URL", "CWA_DB_PATH", "CALIBRE_DB_PATH"):
|
||||
env.pop(name, None)
|
||||
@@ -69,8 +72,69 @@ def test_app_starts_without_any_cwa_configuration(tmp_path):
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "WARNING" 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):
|
||||
|
||||
Reference in New Issue
Block a user