- Python 72.5%
- JavaScript 23.4%
- CSS 2%
- Lua 1.7%
- Dockerfile 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| bindery | ||
| data | ||
| extras | ||
| scripts | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| AGENTS.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Bindery
Bindery downloads web fiction and keeps each story up to date as an ePub. You give it the URL of a story. Bindery downloads the chapters, checks for new chapters each day, and builds the book again after each change. Your reader app gets the books from the OPDS catalog of Bindery.
Bindery uses FanFicFare, so it supports the sites that FanFicFare supports. The tested site is RoyalRoad.
Bindery can also send each book to a Calibre-Web-Automated (CWA) library. See "Calibre-Web-Automated".
How it differs from FanFicFare
FanFicFare builds a book from the chapter list on the site, so a chapter that the author removes also goes from your book. Bindery keeps every chapter that it downloads in its own database, and builds each ePub from that database.
- Chapters that the publisher removes stay in the book. A new chapter goes after the chapter that comes before it on the publishing site. For example, you have chapters 1 to 60, and the publishing site now lists only 1 to 4 and 61. The book then has chapters 1 to 61.
- A changed chapter keeps its old text as a revision. You can compare the revisions and choose the one that goes in the book.
- Replacement rules run when Bindery builds the book. You can change a rule and rebuild the book without a new download.
Quick start
-
Get the code, and make the folder for Bindery's data.
git clone <this repository> bindery cd bindery mkdir config -
Edit
docker-compose.yml. Set the user and group of the container. That user must be able to write to./config. -
Build and start the container.
docker compose up -d --build -
Open
http://localhost:8085. Under "Add stories", enter the URL of a story, and click "Add". Bindery downloads the chapters and builds the book. To get the book at once, click "Download ePub" on the story page. -
Open Settings. Under "OPDS catalog", check "Enable OPDS", and save. In your reader app, add the catalog
http://localhost:8085/opds, with your server address in place oflocalhost.
Bindery then checks each story for new chapters every 24 hours. When a story changes, Bindery builds its book again, and the catalog has the new book.
The example Compose file asks for no login. Set a login before other people can reach Bindery. See "Logins".
OPDS catalog
OPDS lets a reader app browse and download books from Bindery. An admin turns it on with "Enable OPDS"
under Settings > OPDS catalog. The catalog URL is http://localhost:8085/opds, with your server
address in place of localhost. If Bindery runs under URL_BASE, put it before /opds.
The catalog has three lists, with 50 books per page:
- By Title, in title order without a leading "The", "A" or "An".
- By Author, in surname order. Each author leads to their books. A book shows under each of its authors.
- By Last Changed, with the newest change first.
Last Changed records when Bindery stores a new chapter or a new revision of an existing chapter. Rebuilds, metadata edits and rule changes do not update this date.
A book appears when it has a stored, included chapter. It shows its authors, series, description, tags, language and cover, with your kept values in place of the published values. A reader can search by title, author, series and tag. A download uses the stored chapters, the current ePub rules and the download template for the file name. The download link also ends with that name, so a reader that takes the name from the link, such as KOReader, gets the same name. Bindery builds each changed book in the background, so a download does not wait for a build.
AUTH_METHOD sets how a reader logs in:
none: The reader needs no login.local: The reader sends your Bindery user name and password with HTTP Basic authentication. A browser with a Bindery session works too.cwa: The reader sends your CWA user name and password with HTTP Basic authentication. A browser with a Bindery session works too.proxy: Each user sets an OPDS password under Settings > OPDS catalog. Settings shows the user name for the reader, which is the name inPROXY_AUTH_USER_HEADER. The reader sends that name and password with HTTP Basic authentication. The password has 8 to 256 characters, and Bindery keeps a hash of it. Set the proxy to skip its login for the catalog. See "Paths without the proxy login".
Bindery keeps a good login for five minutes. Wrong passwords count toward the lockouts in
"Lockouts and sessions". With TRUST_PROXY=true, each reader has its own lockout.
Use HTTPS outside a trusted local network, because HTTP Basic sends the password with each request.
KOReader
The KOReader plugin extras/koreader/bindery.koplugin downloads new versions of the Bindery books that you
read. The plugin also updates the progress and page count in the library. You do not have to open a
book to see them.
- Add the Bindery catalog in the OPDS catalog of KOReader, with its login.
- Copy
extras/koreader/bindery.kopluginto thekoreader/plugins/folder on your device. - Restart KOReader.
- Open Tools > Bindery > Update books now, in the file browser or in a book.
The plugin updates the books in your reading history whose ePub has the story URL of a Bindery book. It replaces each file in place, so KOReader keeps your position, highlights and statistics. The new progress and page count are an estimate from the change in text size. After you open the book, KOReader shows the exact values. If the update changes the book that you read, KOReader closes and opens that book again, with exact values.
You can also start an update with the "Update Bindery books" gesture action. In the app launcher of the Zen UI plugin, a shortcut to the Bindery plugin starts an update. Tools > Bindery has the option "Update books when Wi-Fi connects", which is off by default. If you turn it on, the plugin updates at most once in 15 minutes, and it does not change the book that you read. If KOReader keeps book metadata in the hash folder, the plugin does not work.
Configuration
Environment variables set the configuration. Restart the container after a change.
| Variable | Description | Default value |
|---|---|---|
DEFAULT_LOCKED_FIELDS |
Fields that new stories lock: title, authors, comments, tags, series, pubdate, languages |
title |
AUTH_METHOD |
none, local, cwa or proxy. See "Logins". |
none |
BINDERY_USERNAME |
The user name for AUTH_METHOD=local. Case-sensitive. |
None |
BINDERY_PASSWORD_HASH |
The password hash for AUTH_METHOD=local. See "Local login". |
None |
BINDERY_ADMIN_USERS |
The users that can change the global settings. See "Admins". | Empty |
PROXY_AUTH_USER_HEADER |
The header in which a reverse proxy sends the user name | X-Remote-User |
PROXY_AUTH_TRUSTED_IPS |
The addresses and networks that can send the user header. See "Logins". | Loopback and private addresses |
PROXY_AUTH_LOGOUT_URL |
Where the Log out button goes in proxy mode | None |
BINDERY_API_KEY |
A key for scripts. See "Scripts". | None |
ALLOWED_HOSTS |
The host names that Bindery answers to. See "Host names". | None |
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 |
A folder that gets each built ePub. With CWA, the CWA ingest folder. Bindery does not make this folder. | None |
CONFIG_DIR |
The story database and personal.ini |
/app/config |
MEDIA_DIR |
The folder for the built ePubs. Bindery keeps the last build of each story. Mount a volume here, so that the builds survive a restart. | /app/media |
TMP_DIR |
The folder for temporary files, such as imported ePubs | /tmp/bindery |
LOG_LEVEL |
The log level. Set DEBUG to log automatic and manual actions. |
INFO |
Separate the values in a list with commas. The variables for CWA are in "Calibre-Web-Automated".
Debug logging
Set LOG_LEVEL=DEBUG in the Bindery process environment, then restart Bindery.
For containers, add LOG_LEVEL: DEBUG under the service's environment section, then recreate the container.
Set it separately for the optional Camoufox helper if you also need browser logs.
Debug logs record automatic checks, queue changes, manual API actions, page and image requests, site waits and resumes. They also record chapter progress, builds, deliveries, CWA requests, and backups. Job and story IDs connect related actions. Idle polling adds no action logs.
Action logs omit request bodies, cookies, credentials in URLs, and URL query values.
FanFicFare, other libraries, and error tracebacks can include private details. Review logs before sharing them.
Set LOG_LEVEL=INFO again when you finish troubleshooting.
Logins
AUTH_METHOD sets how users log in:
-
none: Bindery asks for no login. Use this only on a private network, or behind a proxy that does the login. -
local: One user logs in with a user name and password that you set. See "Local login". -
cwa: Users log in with their CWA user name and password. See "CWA logins". -
proxy: A reverse proxy, such as Rauthy, Authelia or Authentik, logs users in and sends the user name inPROXY_AUTH_USER_HEADER. Make sure that users can reach Bindery only through the proxy, because Bindery trusts this header. If a request gets a 401 because the proxy session ended, Bindery reloads the page, so the proxy can step in again.Bindery accepts the header only from the addresses in
PROXY_AUTH_TRUSTED_IPS. The default is the loopback and private addresses, such as127.0.0.1,10.x,172.16.xto172.31.xand192.168.x. This stops a request that comes straight from the internet, but not one from your own network. Set it to the address of the proxy, for examplePROXY_AUTH_TRUSTED_IPS=172.18.0.3, or publish the port of Bindery only on the host, as127.0.0.1:8085:8085in the Compose file.TRUST_PROXYdoes not change this check. It is only for the lockouts.Paths without the proxy login
An OPDS reader cannot use the login page of the proxy, such as OIDC. Set the proxy to skip its login for
/opdsand each path below it. If you setURL_BASE, put it before the path. As a regular expression, this is^/opds([/?].*)?$. In Authelia, add anaccess_controlrule withpolicy: bypassand this expression inresources. In Authentik, add it to "Unauthenticated Paths" of the proxy provider.On these paths, Bindery checks the OPDS login itself. See "OPDS catalog". Keep the proxy login on
/apiand on all other paths. The health check at/api/healthneeds no login.
If the login settings are incomplete or wrong, Bindery refuses every request, also with the API key, and shows the reason.
Local login
AUTH_METHOD=local gives one Bindery account without CWA. You must set BINDERY_USERNAME and
BINDERY_PASSWORD_HASH. The hash is a one-way form of the password.
-
In the folder with your
docker-compose.yamlfile, run this command to make the hash. The command hides the password as you type it. Use a password that is not empty.docker compose run --rm --no-deps bindery python -c 'from getpass import getpass; from werkzeug.security import generate_password_hash; print(generate_password_hash(getpass("Password: ")))'In a source checkout, you can use:
uv run python -c 'from getpass import getpass; from werkzeug.security import generate_password_hash; print(generate_password_hash(getpass("Password: ")))' -
Make a
.envfile beside thedocker-compose.yamlfile, with your user name and the hash:BINDERY_USERNAME=reader BINDERY_PASSWORD_HASH='paste-the-generated-hash-here'Keep the single quotes around the hash. They stop Docker from reading its
$characters as variables. Keep.envprivate, and do not commit it.If you put the hash in
docker-compose.ymlinstead, write each$as$$, for example'scrypt:32768:8:1$$salt$$digest'. In the Compose file, quotes do not stop Compose from reading$as the start of a variable. Bindery then gets a shortened hash, and it reports that the hash is not valid. -
In the Compose file, set
AUTH_METHOD: local, and uncommentBINDERY_USERNAMEandBINDERY_PASSWORD_HASH. Restart the container. -
Log in with the user name and the password, not the hash.
To change the account, change the user name or make a new hash, and restart Bindery. This ends the open sessions.
Lockouts and sessions
The local and cwa modes lock out password guesses:
- After 5 wrong passwords for one user name from one address in 15 minutes, Bindery locks that user name for 15 minutes, for that address only. The real user can still log in from another address.
- After 20 wrong passwords from one address in 15 minutes, Bindery locks that address for 15 minutes.
Behind a reverse proxy, every request comes from the address of the proxy, so all users share one
lock. To give each client its own lock, set TRUST_PROXY=true. Bindery then takes the client
address from the last entry of X-Forwarded-For. Set it only if clients can reach Bindery through
the proxy alone. Otherwise a client can send a false address with each guess.
A session ends when the user logs out, and always after one day. Without "Keep me logged in", it also ends when the user closes the browser.
Bindery checks a password only at login. A deleted CWA user or a changed CWA password keeps its
open session until that session ends. To stop a CWA user at once, remove the user from
CWA_ALLOWED_USERS and restart Bindery. To end all sessions, delete /app/config/.flask_secret and
restart Bindery.
Admins
In the cwa and proxy modes, BINDERY_ADMIN_USERS lists the users that are admins, for example
BINDERY_ADMIN_USERS=alice, bob. Only an admin can do these things:
- Open the Settings page, and change the settings on it.
- Change the global replacement rules.
- Read and change
personal.ini, which can hold passwords for publishing sites.
Each other user can still add, update and delete stories, and change the settings and the rules of a story. Bindery ignores the case of the names.
If the list is empty, each user is an admin. The none and local modes have only one user, who is
always an admin. The API key is always an admin too.
Host names
Without a login, a web page that you visit can try to reach Bindery from your browser, under a host
name of its own that points at the address of Bindery. To stop this, the none login mode answers
only to these host names:
- An address, such as
192.168.1.5. localhost, and a name without a dot, such asnasor the name of a container.- A name of a private network. These end in
.local,.lan,.home,.home.arpa,.internal,.localhostor.localdomain.
For each other name, Bindery answers with an error that shows the name. If you use such a name, for
example behind a reverse proxy, list it in ALLOWED_HOSTS:
ALLOWED_HOSTS=bindery.example.comallows that name. Bindery then answers only to the names in the list, in each login mode. Write the names without a port.- A leading dot, as in
.example.com, also allows the subdomains. ALLOWED_HOSTS=*allows each name.
The other login modes answer to each name, unless you set a list. The health check at /api/health
works under each name.
Scripts
Scripts can use the JSON API of the web interface.
- In the
local,cwaandproxylogin modes, a script needs the API key. Make a long random key, for example withopenssl rand -hex 32, and set it inBINDERY_API_KEY. Send it with each request asAuthorization: Bearer <key>orX-Api-Key: <key>. - In the
nonelogin mode, a script needs no key, but it must sendX-Requested-With: binderywith each change.
This example adds a story. If Bindery tracks the story already, it downloads the new chapters.
curl -X POST http://localhost:8085/api/stories \
-H "Authorization: Bearer $BINDERY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"urls": ["https://www.royalroad.com/fiction/12345"]}'
The answer lists the queued jobs and the unsupported URLs: {"jobs": [...], "errors": [...]}.
| Request | What it does |
|---|---|
GET /api/stories |
Lists the tracked stories, with their ids |
POST /api/stories |
Adds stories. The body is {"urls": [...]}. |
POST /api/stories/<id>/download |
Queues a job. The body is {"mode": "new"}. The modes are new, recheck (with "chapter_ids": [...]), recheck_all, full, metadata and rebuild. |
POST /api/stories/import-ePub |
Imports an ePub file, for example with curl -F file=@story.epub. Add the form field check=false to skip the check of the site. For one story, use /api/stories/<id>/import-ePub. |
PUT /api/stories/<id>/metadata |
Edits metadata. The body is {"values": {"title": "New title", "tags": ["a", "b"]}}. null removes an edit. The description, comments, is HTML. |
POST /api/stories/<id>/approve |
Approves the library link of a held story. The body is {"uuid": "<uuid of the library book>"}, or {"uuid": null} for a new book. |
PUT /api/settings |
Changes settings. The body {"pause_uploads": true} pauses all uploads to CWA. |
GET /api/jobs |
Lists the recent jobs. Add ?story_id=<id> for one story. |
If Bindery runs under URL_BASE, put the prefix before /api.
FanFicFare settings
On first start, Bindery copies data/personal.ini to /app/config/personal.ini. It uses the format of
personal.ini in the FanFicFare calibre plugin, so you can copy your plugin settings into it. The
INI File page of the FanFicFare wiki
explains the format, and
defaults.ini lists every
setting. Bindery reads the file for each job, so a change applies to the next job.
You can edit the file on the Settings page. Bindery refuses a file that FanFicFare cannot read, and
shows FanFicFare's warnings for other problems. The file can hold passwords for publishing sites, and
each admin can read them. If BINDERY_ADMIN_USERS is not set, each user is an admin. See "Admins".
Bindery always sets always_reload_first_chapter, mark_new_chapters, continue_on_chapter_error,
make_directories, zip_output and calibre_series_meta, because it needs fixed values for them.
It disables FanFicFare's update_check_recent_chapters and update_preserve_deleted_chapters, because Bindery manages chapter checks and retention.
It also sets max_request_retries:0 and retry_http_525_failures:false. Bindery controls request delays and site pauses.
FanFicFare's use_camoufox and do_camoufox_warmup settings need Calibre. Bindery ignores them and uses a separate browser helper.
Use Settings > Browser downloads to control that helper.
Bindery keeps the default maximum image size at 580 by 725 pixels. To change it, set image_max_size in the [epub] section of personal.ini.
Each book gets the cover from the publisher. FanFicFare downloads the cover only if include_images
is on. If it is off, Bindery sets it to coveronly, which keeps the cover but leaves out the images
in the story text.
Software update alerts
Bindery checks its main repository and FanFicFare on PyPI once a day.
A build at an exact version tag checks published stable releases. Other builds check main.
Modified application files also select main. A different commit hash alone does not trigger an alert.
Admins see an alert when either component has an update. Settings > Software shows the installed versions and the last successful checks. Checks never install updates or contact story sites. They send no book content, cookies, or site credentials. Do not update FanFicFare alone. Update Bindery when it supports the newer FanFicFare release.
Bindery keeps check results across restarts. A changed installed version gets a new check.
If a check fails, Bindery keeps its last successful result and tries again in an hour.
An unknown build or failed comparison does not mean that the software is up to date.
Set CHECK_FOR_UPDATES=false and restart Bindery to disable these checks. Compose exposes this option in the Bindery service.
Container builds record the source commit and its exact version tag. Build from a Git checkout that includes its tags.
Git and repository history stay out of the final image.
For source archives or Git worktrees, supply the Docker build argument BINDERY_REVISION with the full commit hash.
For a release build, also supply BINDERY_TAG with its version tag.
Without build identity, Bindery reports its version as unknown but still checks FanFicFare.
Browser downloads
The experimental Camoufox service loads publishing sites in a separate browser.
Settings > Browser downloads controls the helper address, per-site switches, and allowed host names.
The helper downloads its browser automatically. You can also enable regular browser updates and set their interval.
Bindery and the helper share the configuration in CONFIG_DIR/browser-downloads/settings.json.
The setup guide covers local use and the optional Docker service.
The root docker-compose.yml includes the helper under the optional camoufox profile.
After you save the browser settings, run docker compose --profile camoufox up -d --build to start both containers.
Set the helper address to http://camoufox:8191. Its port is not published on the host.
Settings > Request timing applies to browser and direct site downloads, even when the helper is off.
The default minimum delay is 300 seconds. Bindery adds a random wait of up to half the minimum, for 300 to 450 seconds by default.
Larger FanFicFare delays take priority as the minimum. Existing saved settings stay unchanged.
Failed requests also get a delay. HTTP 429, server errors, and connection failures pause all jobs for that site.
The default error pause is 120 minutes. Repeated failures double it, up to one day.
Bindery honors a longer Retry-After wait from the server. These delays are not a site-approved request allowance.
HTTP 403 and known verification or blocking pages pause the site until you review it.
A story that needs a login or that you cannot access fails on its own, and other stories continue.
A failed image on another host does not pause the site. The book leaves out that image.
A paused site shows at the top of the Settings page. Use Resume there after you resolve the access problem.
Timed pauses end automatically. You can also use Resume to end any pause early, including HTTP 429 and Retry-After waits.
Resume requeues waiting downloads for that site and clears their old pause messages. Saved chapter progress stays intact.
Running jobs and jobs for other sites stay unchanged.
Normal request spacing still applies. Another failure can pause the site again.
Pauses survive a restart. Browser and direct image requests do not retry automatically within a download attempt.
Reverse proxy
To serve Bindery under a path, such as /bindery/, do one of these:
- Make the proxy remove the path before it forwards the request.
- Make the proxy forward the full path, and set
URL_BASE=/bindery.
The health check at /api/health works with and without the prefix.
Import an ePub file
If the publishing site removed chapters before Bindery tracked the story, import an ePub that you made earlier with FanFicFare. Bindery stores its chapters as if it downloaded them. Chapters that the site publishes later go after them.
- To start to track a story from its ePub, use "Import an ePub file" on the stories page. For a story that Bindery tracks already, it adds the chapters that it does not have.
- To add chapters to one story, use "Import chapters from an ePub" on the story page. The ePub must be of that story.
Bindery accepts only ePubs that FanFicFare made, because it reads the story URL and chapter data that FanFicFare writes into each ePub. A chapter that Bindery stored already keeps its stored text. The other chapters go in the order of the ePub.
After the import, Bindery checks the publishing site for new chapters and builds the book. If you clear "Check the site for new chapters after import", Bindery only rebuilds the book of a story that it downloaded before.
File names of downloaded books
"Download ePub" on a story page and each download from the OPDS catalog send the book with a file
name. A template gives the name. The default template is {title_sort} - {authors}. For "The Book"
by Ann Writer, it gives Book, The - Ann Writer.epub. That is the name that Calibre's default template gives.
An admin can change the template under "File names" in Settings. To go back to the default,
clear the field and save. The template follows
the rules of Calibre's templates for file
names. Bindery adds .epub. For example, {series:|| - }{series_index:0>2s|| - }{title} - {authors}
gives The Saga - 02 - The Book - Ann Writer.epub.
In Calibre, {title} and {series} give the title sort in a file name, for example Book, The.
In Bindery, they keep the article. This is the same as Calibre with the tweak
save_template_title_series_sorting set to strictly_alphabetic. Use {title_sort} and
{series_sort} for the title and the series with the article at the end. {series_sort} is a
Bindery field. Calibre does not have it, and gives empty text for it.
| Field | Value |
|---|---|
{title} |
The title: The Book. |
{title_sort} |
The title with a leading "The", "A" or "An" at the end: Book, The. |
{authors}, {author} |
The authors, with & between them. |
{author_sort} |
The authors with the last name first: Writer, Ann. |
{series} |
The series. |
{series_sort} |
The series with a leading article at the end: Saga, The. |
{series_index} |
The series number: 2, or 2.5. |
{tags} |
The tags in sorted order, with , between them. |
{languages} |
The language, as Bindery stores it. |
{publisher} |
The publishing site. |
{pubdate} |
The published date. |
{timestamp} |
The date when Bindery added the story. |
{last_modified} |
The date when the chapters last changed. |
{id} |
The Bindery story ID. With CWA on, the CWA book ID of a story that has a library book. |
- A field with no value becomes empty text.
{field:format|text before|text after}adds the two texts only when the field has a value. Each of the three parts can be empty.{series:|| - }adds-after the series.- The format is a Python format.
{series_index:0>3s}gives002, and{title:.20s}gives the first 20 characters.{author_sort[0]}gives the first character. {{and}}give braces. Bindery makes each run of spaces one space.- A download has one file name. If the template has folders, Bindery uses the part after the last
/. That is the name that Calibre gives the file. - Bindery does not support template functions, such as
{title:uppercase()}, or the program modes of Calibre. It refuses such a template when you save it. - If the template fails for a book, or gives an empty name, Bindery names the file after the title. The log has a warning.
- Bindery replaces each character that a file name cannot have with
_. A name has 100 characters and 200 bytes at most.
"Date format" sets the form of {pubdate}, {timestamp} and {last_modified}. It takes the
codes of Python's strftime. %d
gives the day, %b the month name, %m the month number and %Y the year. The default is
%b, %Y, as in Calibre. It gives May, 2020. To go back to the default, clear the field and save.
A / in a date becomes _, as in Calibre.
Bindery follows Calibre 9.2.1 for English, with the tweak above. The order of tags with letters
outside ASCII can differ from Calibre. The template applies only to downloads in the browser. It
does not change the names of the files in INGEST_DIR. The browser can ignore the name.
Download modes
| Mode | What it does |
|---|---|
| Download new chapters | Downloads the chapters that are not stored yet. The default, and what scheduled updates use. |
| Recheck selected chapters | Downloads the selected chapters again. Changed text becomes a new revision. |
| Recheck all chapters | Downloads every chapter on the publishing site again, and new chapters too. |
| Download and replace entire book | Downloads every chapter again. Chapters that the publishing site no longer lists leave the book. They stay stored, and you can put them back. |
| Update metadata from site | Updates only the metadata in the ePub. |
| Rebuild book | Builds the book again from the stored chapters. |
Bindery builds a book only if the book changed. A change is a new, changed, moved or renamed chapter, a changed rule, or changed metadata. A job that finds no change answers "No changes". Only "Rebuild book" builds a book that did not change. With CWA on, Bindery sends each book that it builds to CWA.
ePub build processes
Each ePub build runs in a separate process that exits after one book. The operating system then releases that process's memory. The main app still holds download data and caches. Memory use during a build can remain high.
The build process uses private copies of the book inputs and personal.ini in TMP_DIR.
It uses Bindery's account, starts no server, and does not open the live database.
It blocks Python network calls and attempts to start other programs. This isolates memory, but it is not a security sandbox.
Bindery builds one book at a time. Only the main app replaces the cached ePub, after the build process exits successfully.
A failed build leaves the previous ePub in place. A build stops after 30 minutes or when its parent process exits.
Bindery removes temporary copies after completion or a handled failure.
An abrupt process or container shutdown can leave private epub-build-* folders in TMP_DIR, and
building-*.epub files in MEDIA_DIR.
At startup, Bindery removes these, and old .build-* folders in MEDIA_DIR. It checks only the top level of each
folder, and it skips symbolic links. Bindery logs cleanup errors and continues startup.
Each running Bindery must have its own TMP_DIR and MEDIA_DIR, because this cleanup removes the build files of
any other Bindery that shares them.
Spread out checks
If you start a download that waits for its scheduled check, the download no longer waits for that time. It runs after any download that is ready before it. A site pause still applies. The story keeps its time for later checks.
In Settings, under New chapter checks, set the check interval in whole days. The default is 1 day. Clear the field and save to restore the default. Enter 0 to turn automatic checks off. Bindery rounds old hour-based settings up to whole days. A saved value of 0 stays off. Save to apply the change without a restart. This saved value survives a restart. It also sets the delay before automatic checks resume after a cancellation. It does not change full-recheck intervals.
With a positive interval, click "Spread out next checks" to space checks across that interval. Bindery sorts checks by their next due time. It includes waiting site downloads and future checks for books with automatic updates on. Running jobs and local builds stay unchanged. Waiting jobs keep their saved chapters and progress. The schedule survives a restart and does not change the recorded check history.
Later automatic checks keep their assigned times within each interval, rather than count from the time a download finishes. If you change the interval, the spacing changes in proportion. Manual downloads do not reset the assigned times. Books with automatic updates off get no recurring checks. Click the button again to replace the schedule.
Site pauses and cancellation delays still apply. These delays and long downloads can push checks past their planned times. Delayed checks can run close together, but later checks return to their assigned times.
Find edited chapters
Scheduled updates download only new chapters, so they miss most edits. To find edits, run a recheck, or use one of these settings:
- On the Settings page, "also recheck the last N chapters" rechecks the last N chapters with each download of new chapters. Authors most often fix their latest chapters. The default is 0, which turns it off.
- In the "Checking for new chapters" section of a story page, "Recheck all chapters every N days" rechecks the whole story. This recheck takes the place of the next scheduled update. The story then ignores the "last N chapters" setting.
Each rechecked chapter is one request to the publishing site, so keep the numbers small for long stories.
If the publishing site changes the title of a chapter, "Download new chapters" and "Recheck selected chapters" download that chapter again. The chapter gets the new title only if the download works. On sites that number chapter URLs, such as FanFiction.net, this also finds chapters that moved when the author inserted a chapter.
Revisions
A recheck compares the text of a chapter with its newest revision, which is the text that the publishing site sent last. Bindery stores the HTML as the publishing site sends it, before the rules, so a rule never makes a chapter look changed.
- If the text changed, the new revision goes in the book. If you chose an older revision, it stays in the book until the publishing site changes the chapter again.
- On the page of a chapter, you can compare revisions, choose the one that goes in the book, and delete the others. The revision in the book and the newest revision cannot be deleted.
- A chapter imported from an ePub holds its text after the FanFicFare settings and rules of that
build. If the current rules turn the published text into the imported text, Bindery counts the chapter
as unchanged, and stores the published text in its place. Other differences, for example from settings
in
personal.ini, show as a change.
Restarts
Bindery stores each chapter as soon as it downloads it, and keeps the job queue in its database. After a restart, the waiting jobs run. A job that was running starts again at the front of the queue, and skips the chapters that it stored. If Bindery stops during the same job three times, the job fails.
If a job thread stops, /api/health answers 503, so the container shows as unhealthy. Restart
Bindery to start the thread again.
Backups
Once a day, Bindery copies its database, personal.ini and browser download settings to
/app/config/backups, and keeps the copies of the last 7 days. The database is the only copy of the chapters that publishing sites removed. The
backups are on the same disk, so also copy /app/config to another disk or machine.
To restore a backup, do these steps:
- Stop Bindery.
- Copy the backup, for example
/app/config/backups/bindery-2026-09-26.db, to/app/config/bindery.db. - If
/app/config/bindery.db-walor/app/config/bindery.db-shmexist, delete them. They belong to the old database. - Start Bindery.
Metadata
Bindery keeps the metadata of each story in its database, and writes it into each ePub. The fields are title, authors, description, tags, series, published date and language. Each field is locked or unlocked for each story:
- An unlocked field takes the published value on every update.
- A locked field keeps its stored value. The first download sets that value. For a story that you import from the library or from an ePub file, the value comes from that ePub.
- To update a locked field once, select it under "Also take these locked or edited fields from the site" when you start a download. The field stays locked.
You can edit each field under "Metadata" on the story page. An edit of a locked field replaces its stored value. An edit of an unlocked field stays until the site changes that field. To go back to the site value at once, click "Use the site value" or unlock the field. If Bindery sent the book to CWA before, it rebuilds the book after an edit.
- Separate authors with
&, and tags with commas. - Put the number of a series in brackets, for example
Saga [2]. - The description is plain text, with a blank line between paragraphs.
DEFAULT_LOCKED_FIELDS sets the locks for new stories. The default is title. The publisher is
always the site name.
Page count
The story list shows an estimated page count beside the chapter count. The OPDS catalog shows it in the description of a book. Bindery counts the pages as the last step when it builds an ePub, so the count includes your replacement rules and the line-break setting. A story shows no page count until Bindery builds its book.
The method comes from "Paragraphs (APNX Accurate)" in the Count Pages plugin for calibre:
- A paragraph start is one line. Each 70 characters of text is one more line.
- A page is 31 lines.
- A page is at most 2400 characters. This limit sets the count of a book with almost no paragraphs.
The plugin counts either the <p> elements or the <div> elements of a book as its paragraphs. Bindery
counts each paragraph that a reader sees:
- Text starts a paragraph after a
<br>, and after the start or the end of a block element such as<p>,<div>,<li>or a heading. - A run of
<br>elements is one paragraph break. - An element that only holds other blocks is not a paragraph.
- The cells of a table row share one line.
- A paragraph with no text is not counted. This includes a paragraph that holds only a no-break space, which some stories put between paragraphs as a blank line.
So the same text gives the same count as <p> elements, as <div> elements, and as lines with <br>
between them. For a book that uses <p> elements and has no empty paragraphs, the count is within about 1%
of the count of the plugin. The plugin counts each empty paragraph as a line, so its count is higher for a
book that has many of them.
The count is an estimate and differs from the page count of a printed book.
Replacement rules
A rule is a regular expression, a replacement and optional flags (i, s, m, x). Bindery uses
the Python regex module, which accepts the syntax of Python's
re module. For an
introduction, read the Regular Expression HOWTO. The
replacement can use groups, such as \1 or \g<name>. When Bindery builds a book, it runs the
global rules from the Settings page, then the rules of the story from its page. The rules run on the
HTML of each chapter. Each rule editor has a preview that shows one chapter before and after the
rules.
If a rule takes longer than 10 seconds on one chapter, the build stops with an error that names the rule.
Some sites separate paragraphs with <br> tags. For those, turn on "Turn line breaks into
paragraphs before the rules run" in a rule editor. Bindery then puts each piece of text between
<br> tags in its own <p>, and drops empty pieces. A <br> inside other markup, such as italics,
stays. This step runs before the global rules. The global setting is off by default, and applies to
each story that has no setting of its own. To see the new paragraphs in the preview, select "Compare
HTML source".
Calibre-Web-Automated
Bindery can send each book that it builds to a Calibre-Web-Automated (CWA) library, and update that book when the story changes. CWA is off by default. To turn it on:
- Set
CWA_ENABLED=true. - Set
INGEST_DIRto the CWA ingest folder. SetCWA_URL,CALIBRE_DB_PATHor both. - Restart Bindery.
Use CWA v4.0.1 or later. Older versions can import an update as a new book. See "Limitations".
An earlier Bindery used CWA unless CWA_ENABLED was false. After an upgrade, set CWA_ENABLED=true
to keep the uploads to CWA.
| Variable | Description | Default value |
|---|---|---|
CWA_ENABLED |
true to use CWA |
false |
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 |
CALIBRE_DB_PATH |
The calibre metadata.db, for book ids. See "How books reach the library". |
None |
CWA_DB_PATH |
Calibre-Web's app.db, for logins. See "CWA logins". |
None |
CWA_ALLOWED_USERS |
The CWA users that can log in. Empty allows every CWA user. | Empty |
The example Compose file has these values as comments. The user of the container must be able to write to the CWA ingest folder. Usually this is the same user and group as CWA.
Settings shows an alert for each missing value:
- Neither
CWA_URLnorCALIBRE_DB_PATHis set. The alert is under "Uploads to CWA". Library linking is then off, and its checkbox is disabled. Set either value and restart Bindery. Bindery keeps your saved linking preference. INGEST_DIRis not set. The alert is under "Sending books". "Pause all uploads" is then checked until you setINGEST_DIRand restart Bindery.
At each start, the log has a warning for each missing value.
Without CWA
Without CWA_ENABLED=true:
- Bindery makes no request to CWA. It ignores
CWA_URL,CWA_USERNAME,CWA_PASSWORD,CWA_DB_PATHandCALIBRE_DB_PATH. If one of them is set, the log has a warning at each start. - The web interface shows nothing about CWA. The pause, the library links and the approvals do not apply.
- If
INGEST_DIRis 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 the earlier files stay in the folder. IfINGEST_DIRis not set, Settings shows an alert under "Sending books". AUTH_METHOD=cwadoes not work. Uselocalorproxy. On a private network, you can usenone.
How books reach the library
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.
- Bindery puts the ePub of a new story into the ingest folder. CWA imports it as a new book.
- 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 the CWA OPDS feed (usingCWA_URL,CWA_USERNAMEandCWA_PASSWORD) for the story title. The search ignores the parts of a title in parentheses or brackets, such as tags or "(Book 1 Complete)". Bindery downloads up to 5 ePubs with a matching title, closest title first. The book whose ePub holds the story URL is the match. Bindery saves the id and uuid of the book with the story. - For the update, Bindery puts two files into the ingest folder: the new ePub, and a sidecar file
<name>.epub.cwa.jsonthat holds{"action": "add_format", "book_id": N}. CWA then replaces the ePub of book N.
Before each later update, Bindery finds the book by its uuid, first in the library database if you
set CALIBRE_DB_PATH, then through CWA. If you deleted the book in CWA, Bindery unlinks the story.
CALIBRE_DB_PATH is optional. It is the path of the calibre metadata.db inside the container, and
it lets Bindery find a book by its story URL instead of its title. To use it, mount the library
folder read-only into the container. Mount the whole folder, so that SQLite can read the journal
files beside the database. The Compose file has an example. Bindery does not write to this database.
Be sure to use the library that gets the ingest files. A different library can make Bindery update the
wrong book. Database lookups also find books that the CWA account of Bindery cannot see. If Bindery
cannot read the database, it uses CWA for every lookup. Keep CWA_URL and the CWA account set,
because library imports and CWA lookups use them.
A book that the FanFicFare calibre plugin made holds its story URL. If you add its URL, or import it from the library, Bindery links that book and imports its chapters from the ePub.
CWA reads the metadata and the cover of an ePub only when it imports a new book. An update replaces only the ePub file. So edits that you make in CWA stay, and later changes on the site show in the ePub but not in the CWA catalog.
If Bindery cannot find the book
If library matching is on and configured, an update fails when Bindery cannot find the book. The story page shows the error "Cannot find this story in CWA. Link to library book, or send as new book." The chapters stay stored, and the next successful update includes them. This happens if:
- CWA did not import the ePub.
- The title of the book changed in CWA before Bindery linked it.
- The CWA account of Bindery cannot see the book.
- You deleted the book in CWA.
To fix it, go to "Library link" on the story page, and do one of these:
- Search for the book, and click "Link". Then click "Rebuild book" to send the update.
- Click "Send to CWA as a new book". Use this if CWA did not import the first version, or if you deleted the book.
Upload settings
The "Uploads to CWA" section of Settings controls library matching and uploads. Bindery still downloads chapters and builds books when uploads stop. The story list marks a built book with "held" only when it waits for approval, library linking is on, and uploads are not paused. Bindery keeps track of all unsent books.
- Link books to CWA library. This defaults to on when
CWA_URLorCALIBRE_DB_PATHis set. Without either value, it stays off and disabled. Turn it off to skip matching with a configured library connection. Uploads use saved CWA book IDs when available. Without an ID, every build goes toINGEST_DIRas a plain ePub. If CWA imports these files, updates will create duplicate books. Settings and the story page show a highlighted warning. A change of this setting sends the unsent books, unless uploads are paused. - Hold new links for approval. This setting applies only when library linking is on. Use this to catch a story that is linked to the wrong CWA book. The first upload of a new story waits for your approval. The first upload after a change of the library link also waits. This includes a link that you make, and a book that Bindery finds after the linked book is gone. The story page names the book that gets the update, or says that the story goes to CWA as a new book.
- Pause all uploads. No book goes to CWA. This is always checked when
INGEST_DIRis unset. When you turn the pause off, Bindery sends the unsent books. The other upload settings still apply. While paused, use "Approve" or "Approve as a new book instead" to save a decision without sending the book.
To approve a link, open the story page and do one of these:
- If the book is right, click "Approve and send".
- If the book is wrong, search for the right book under "Library link", and click "Link". Then approve the new link.
- If CWA does not have the story, click "Send as a new book instead".
The book that CWA imports from the first upload of a story needs no approval. After an approval, later updates go to CWA as usual.
The CWA account
Make a CWA user for Bindery with these settings:
- The download permission. Bindery reads story URLs and chapters from the ePubs.
- "Show Publisher Section", in the sidebar settings of the user. CWA shows its list of publishers in OPDS only to users with this setting. "Import from the library" uses the list to find the books of publishing sites, because FanFicFare sets the publisher of a book to the site name.
- Access to all books. Bindery finds only the books that this account can see.
Bindery sends the password of this account with each OPDS request. Use a password that no other service uses.
If CWA allows anonymous browsing, Bindery needs no account. Leave CWA_USERNAME and CWA_PASSWORD
unset, and give the guest user of CWA the settings above.
CWA logins
AUTH_METHOD=cwa needs CWA_ENABLED=true, and CWA_URL, CWA_DB_PATH or both. Without CWA_DB_PATH, Bindery sends the
user name and password to CWA_URL/opds as an HTTP Basic login. This is the OPDS login.
The OPDS login works only if CWA refuses a wrong password. A CWA with anonymous browsing can
accept every OPDS login. Bindery tests this with a login that cannot exist. If CWA accepts it,
Bindery refuses each OPDS login, and the log names the reason. Set CWA_DB_PATH then, or use
another AUTH_METHOD.
With CWA_DB_PATH, Bindery checks the password against the account in Calibre-Web's app.db. A
wrong password or an unknown account fails at once. If Bindery cannot read the file or its password
hash, it uses the OPDS login.
CWA_DB_PATH is the path of app.db inside the container. To use it, mount the CWA config folder
read-only into the container. Mount the whole folder, so that SQLite can read the journal files
beside the database. Bindery does not write this database.
Database logins check only the password. CWA's external login providers and multi-factor
authentication do not apply. If they must apply, use the proxy mode.
- The CWA guest user cannot log in.
- If
CWA_ALLOWED_USERSis set, only those users can log in. Bindery checks this list on each request. CWA_USERNAMEandCWA_PASSWORDare only for the library lookups of Bindery.
Limitations
- Bindery identifies a chapter by its URL. Most sites, such as RoyalRoad and AO3, keep the URL of a chapter. FanFiction.net and similar sites number chapter URLs by position. When an author inserts a chapter there, Bindery finds the moved chapters by their new titles. If the titles stay the same, run "Recheck all chapters". When an author removes a chapter there, the book loses that chapter, and the last chapter appears twice. The removed chapter stays as an older revision of the chapter that took its URL.
- "Stop tracking" deletes only the data of Bindery. A book in CWA stays there.
- Updates of a CWA library need CWA v4.0.1 or later. CWA v4.0.0 added the
.cwa.jsonsidecar files. But v4.0.0 can delete a sidecar file before it imports the ePub, and then imports the update as a new book. Versions before v4.0.0 cannot read sidecar files, so each update becomes a new book.
Development
You need uv. The frontend tests also need Node 22 or later.
Tests
Run these commands in the project folder:
uv sync # install the Python dependencies
uv run pytest # Python tests
uv run ruff check . # lint
node --test "tests/js/*.test.mjs" # frontend tests
The Python tests use a fake CWA server and temporary folders, so they need no CWA and no story sites. The frontend tests use the built-in test runner of Node, and need no npm packages. Also check the pages in a browser.
Run locally
CWA is off by default. Bindery puts each new build of a story into its ingest folder as an ePub. This is enough to work on downloads, chapters, rules and the web interface.
mkdir -p dev/ingest
CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp MEDIA_DIR=dev/media 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
"Download ePub" on the story page.
Run locally with a test CWA
To test library lookups and updates, run a separate CWA container with its own empty folders. Do not use your real library.
-
Start a test CWA on port 8083. It shares
dev/ingestwith Bindery, and makes an empty library indev/cwa-libraryon first start.mkdir -p dev/cwa-config dev/cwa-library dev/ingest docker run -d --name cwa-dev -p 8083:8083 \ -e PUID=$(id -u) -e PGID=$(id -g) \ -v "$PWD/dev/cwa-config:/config" \ -v "$PWD/dev/ingest:/cwa-book-ingest" \ -v "$PWD/dev/cwa-library:/calibre-library" \ crocodilestick/calibre-web-automated:latest -
Open
http://localhost:8083, and log in asadminwith the passwordadmin123. These are the default credentials of CWA. -
Start Bindery with the test CWA.
AUTH_METHOD=cwamakes Bindery ask for a CWA login, so you can test logins too.CWA_ENABLED=true CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp MEDIA_DIR=dev/media \ CWA_URL=http://localhost:8083 CWA_USERNAME=admin CWA_PASSWORD=admin123 AUTH_METHOD=cwa \ uv run python -m bindery
To remove the test CWA, run docker rm -f cwa-dev, then delete dev/.
The web interface is in bindery/static, as plain JavaScript modules, so a browser reload shows
each change. After a change to the Python code, restart python -m bindery.
Keep gunicorn at one worker. The job threads run inside that worker.
License
Apache License 2.0. See LICENSE.