A browser-based e-book library and reader for your own collection. Browse, search and read from a Mac, an iPad or a phone, with your place in each book kept on the server so every device agrees.
Built as a sibling to a personal DLNA gateway: FastAPI, SQLite and a vanilla front end with no build step. Rendering is foliate-js (MIT), pinned as a submodule.
Status: works and is in daily use on a library of ~2,400 books in seven formats, but it is a personal project rather than a product.
EPUB, MOBI, AZW3, PRC, PDF, CBZ and CBR — including EPUBs that were never zipped and are stored as directories, which are zipped on the way out so nothing downstream needs to know.
Comics packed with RAR are read through libarchive — which macOS
ships as /usr/bin/bsdtar — and repacked as a zip on the way out, the
same trick an unpacked EPUB gets. That is 205 more comics. libarchive
refuses ten of them, one run of 2003-vintage archives, and those are
reported and skipped rather than taking a scan down.
Three rendering models sit behind those seven: reflowed text, rasterised pages (PDF, via pdf.js), and images page by page (CBZ, CBR) — so the typography controls apply to the first and not the other two.
Books the shop locked are excluded, not listed as titles that fail. An Apple Books purchase has every document encrypted and no reader outside Apple Books can open it. There are none left in this library — it held 50 once — but the rule stays, because encrypted fonts are a different thing entirely: 8 books here obfuscate their fonts, the Earthsea novels among them, and excluding on the encryption file alone would lose all 8 books that read perfectly.
- Browse by Titles, Authors, Series, Genres, Formats, Years or Shelves, with a letter bar and drill-down. Formats groups what a book is rather than what it is packed in: EPUB, MOBI (with AZW3 and PRC, the same Palm container), PDF, and Comics — where a CBZ and a CBR are one shelf, because the difference is how the pictures are packed.
- Search across titles, authors, series and genres (SQLite FTS5, with
diacritics folded —
le carrefinds le Carré). - Read with four themes, adjustable type, paginated or scrolled flow, and a table of contents. On a phone or an iPad the reader takes the whole screen, and an Aa button brings the panels in over the page.
- Highlight and annotate — four colours, notes, and a Highlights panel that jumps to the passage. Shared across devices, like your place.
- Shelve from the list — make a shelf, throw one away, and put a book on or take it off without opening it.
- Mark a book read — from the list or from its detail page, and it moves from Reading now to Finished, keeping your place. Books you finished years ago on something else can say so too.
- Remembers your place server-side, keyed by content hash, so renaming or moving a file does not lose it.
- Shelves you make, plus Reading now and Finished, which are derived from reading state rather than maintained by hand — so nothing puts a book on one, and the way off is to say you have finished it, or stopped.
- Indexes ~2,400 books in about 8 seconds, and re-scans in under a second.
A collection assembled over thirty years carries other people's filing. Every pass below is dry-run by default, database-only, and reversible. No book file is ever renamed, moved, written to or deleted — the library is opened read-only.
./run.sh repair # check authors and titles, change nothing
./run.sh repair --apply # ...and fix what it can prove
./run.sh dedupe [--apply] # one row per work, spares kept and restorable
./run.sh series [--apply] # read series from filenames
./run.sh names [--apply] # author, series and title from one filename
./run.sh coauthors [--apply] # "(with Roberto Fuentes)" is a second author
./run.sh tags [--apply] # "(v2.1)", "(not ocr)", underscores-for-spaces
./run.sh fragments [--apply] # finish a title that was cut off, via a provider
./run.sh covers [--apply] # a cover for books showing a lettered tile
./run.sh covers --online # ...and ask a provider for the rest, slowly
./run.sh forget [--apply] # remove books whose files are gone
./run.sh refresh [--max N] # re-ask about the oldest author dataYou do not have to run the free ones. A scan reads the names it
finds — author, series and title out of the filename, a co-author freed
from a title, the scanner's notes taken off — so dropping books into
LIBRARY_ROOT and restarting is the whole workflow. Set
TIDY_ON_SCAN=false in .env to stop it. What it will not do
unasked is reach a provider: fragments and REPAIR_ON_SCAN are
opt-in, because a request costs somebody something.
names and fragments take --author "Name" and --format pdf so a
run can be aimed and then read — coauthors and tags are cheap,
local and library-wide. Aiming matters:
every one of these passes was improved by looking at what a dry run
proposed before applying it, and the library-wide names run was caught
proposing four wrong authors that way.
./run.sh names --author "Piers Anthony" --format pdf --applyOne filename can hold three facts. Anthony, Piers - Xanth 08 - Crewel Lye.pdf is an author, a series with a number, and a title, and
until names read it the book browsed as one run-on line credited to
nobody. The rules are narrow on purpose: the author must lead in
Surname, First form, a series is believed only where two books by that
author agree on it, and where nothing corroborates a series the words are
left whole rather than cut at a guess.
A provider can finish a title the filesystem cut off. The Caterpillars Questi matches nothing anywhere, for ever; trimmed to the
last whole word it finds The Caterpillar's Question. The answer must
complete the fragment word for word, so the rule can only ever
lengthen a name — it cannot swap in a different book.
Covers come from the book's own first page, but only where that page
is a cover — one image covering it, with no text over it. A title page
is a worse tile than the letter it would replace. That is 144 of this
library's PDFs; the audit that followed found 14 title pages among 159,
and DELETE /api/books/{id}/cover is how you say so. What you drop stays
dropped.
For the books whose files hold no image at all, --online asks Open
Library — one request a minute, so a full sweep is an overnight job
rather than a ten-minute one, and it is resumable. Each book is asked
about once ever, whether or not there was an answer. About 70% get an
offer and roughly three in four of those are the right book, so this
trades a few wrong covers for several hundred right ones; a wrong one
goes with the same endpoint.
A scan notices a deletion but never acts on one: the row is marked
missing and kept, so a vanished book leaves the shelves at once while
your place in it survives an unplugged drive. forget is the deliberate
step, and it refuses when most of the library is missing — that is a
disk that is not mounted, not a library you emptied.
Where nothing automatic can help, you can say so yourself.
PUT /api/books/{id}/authors fixes a file that is confidently wrong
about its own author, and PUT /api/books/{id}/series files a book no
rule could place — The Light of All That Falls is book three of the
Licanius Trilogy, and neither its file nor its name says so. What you
say is locked against every future scan.
What that found here: 607 authors became 577 (misspellings and spacing);
193 books filed under their own title got a real author; 731 titles
that were really filenames were corrected, and 303 books that were
credited to nobody — or to harry, whoever ran the scanner — now name
their author; 288 spare copies are one row each; and 235 series exist
where there had been 39.
Every one of those passes was run as a dry run and read before it was applied, which is the only reason the numbers are worth quoting: the library-wide author run proposed four names that were not names, and the last duplicate audit caught a book about to be hidden behind its own sibling.
Everything a provider says is cached, so an author is looked up once, ever. A weekly launchd job re-asks about the ten oldest, which walks the whole library in about forty weeks without ever looking like a bulk sweep — Open Library allows 3 requests/second to a client that identifies itself, and asks that its APIs not be used as a bulk backend.
| Python | 3.13 or newer — the SQLite it bundles must be 3.44+, which is checked on the way in and reported plainly if not |
| Node | for the UI tests only; the app itself has no build step and no JavaScript dependencies to install |
| A folder of books | anywhere. Nothing is ever written to it — it is opened read-only |
Optional: bsdtar |
for comics packed with RAR (.cbr). It is libarchive, and it ships with macOS |
Everything else is three Python packages — FastAPI, hypercorn, Pillow — plus a vendored copy of foliate-js. No database server, no message queue, no container, no cloud account.
git clone --recurse-submodules https://github.com/YOUR-NAME/epub-reader.git
cd epub-reader
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt # or requirements.txt to just run it
cp .env.example .env # then set LIBRARY_ROOT to your books folder
./run.sh start # scans the library, then serves on :8770Open http://localhost:8770. That is the whole install.
The service is plain Python and behaves the same everywhere; only the shell wrapper and the "start it at login" step differ.
macOS — as above. ./run.sh is launchd-aware: copy
com.example.epub-reader.plist to ~/Library/LaunchAgents/, edit the
paths inside it, and launchctl load it to have the service start at
login. Set LAUNCHD_LABEL in .env to match if you rename it.
bsdtar is already installed, so .cbr comics work out of the box.
Linux — identical, including ./run.sh. For start-at-login use a
systemd user unit instead of the plist:
# ~/.config/systemd/user/epub-reader.service
[Service]
WorkingDirectory=/path/to/epub-reader
ExecStart=/path/to/epub-reader/.venv/bin/python epub_gateway.py --env .env
Restart=on-failure
[Install]
WantedBy=default.targetsystemctl --user enable --now epub-reader. For .cbr comics install
libarchive's tools (apt install libarchive-tools, dnf install bsdtar); everything else needs nothing.
Windows — run.sh is a bash script, so either use WSL (where the
Linux instructions apply unchanged) or run the service directly, which
is all run.sh does anyway:
py -m venv .venv
.venv\Scripts\pip install -r requirements.txt
copy .env.example .env # then set LIBRARY_ROOT
.venv\Scripts\python epub_gateway.py --env .envPaths in .env take Windows form (LIBRARY_ROOT=C:\Users\you\Books).
For start-at-login, Task Scheduler with "run whether user is logged on or
not". .cbr comics need bsdtar on PATH — it comes with Git for
Windows and with libarchive.
./run.sh also takes stop, restart, status and scan, and
start --foreground to watch it run. Every start rescans, so books
dropped into the library folder are picked up without a separate step.
All configuration lives in .env — ports, paths, TLS certificates,
request pacing and any API keys. Nothing is hardcoded and there is no
second config file. .env is gitignored; .env.example documents every
key.
.venv/bin/python tools/make_test_library.py # writes .test-library/ and .env.test
./run.sh --env .env.test startThat builds a handful of synthetic books — one of each format, and deliberately awkward ones: a book whose whole text is a single huge section, one with a comma in its internal filenames, one stored as a directory, and an EPUB carrying hostile JavaScript. Every one of those broke something real.
Set TAILSCALE_CERT_HOST, and either put <host>.crt and <host>.key
next to the .env file or point TLS_CERT_FILE / TLS_KEY_FILE at
them. A missing certificate is reported plainly rather than guessed at.
| Python modules | 39 |
| Tests | 1,181 pytest across 45 files, 428 Playwright across 15 specs |
| Linting | ruff, 0 findings — enforced by a test, not by hope |
| Security | threat model and reporting in SECURITY.md |
The badges at the top are generated from the suites, not typed in —
tools/badges.py --write rewrites them and tests/test_badges.py fails
when they have drifted. A number nobody verifies is worse than no number,
because a reader believes it.
.venv/bin/python -m pytest # code
npx playwright test # UI, against Chromium and WebKit
.venv/bin/ruff check . # linting — also run by tests/test_lint.pyThe linter was added late and found 116 things and no bugs, which is
the point twice over: none of it was wrong, and nothing had been
checking. The tell was eleven unused # noqa comments — suppressions
written for a linter that had never existed. ruff.toml selects rules
rather than taking a default, and the two genuine false positives are
suppressed with the reason written next to them rather than blankly.
tests/test_lint.py runs ruff and fails on anything, because a tidy-up
nobody enforces lasts until the next commit.
The UI suite runs against both engines on purpose: this is mostly read in Safari, and a Chromium-only suite once stayed green while books failed to open in Safari entirely. It also runs serially — reading position and highlights are shared server-side by design, so parallel tests would race over real application state. No test touches the network: the fetcher is injected and refuses.
EPUBs can contain JavaScript, and foliate-js states plainly that iframe
sandboxing cannot contain it. A Content Security Policy blocking all
non-self scripts is therefore mandatory and ships by default. The test
suite proves the containment by rendering a hostile EPUB carrying inline
and external scripts and asserting neither runs — and that test is itself
verified by weakening the policy and watching it fail.
PDF support needed no relaxation of that policy: pdf.js runs its parser in a Web Worker, and a same-origin worker is already allowed. A test watches the console for refusals while a PDF opens, so if that stops being true it is a failing test rather than a blank page.
There is no authentication, and that is the shape of the thing: it is a personal service for one household, so it acts for whoever can reach the port. Two consequences are handled rather than hoped about.
EPUB_BIND defaults to loopback, and widening it onto a tailnet or a
LAN is a deliberate edit with the reason written next to it.
And a request from somebody else's page is refused. Nothing here uses
cookies, so a cross-site request cannot be authenticated — but it does
not need to be: POST /api/books/forget-missing?apply=true&force=true
drops reading positions and highlights for whoever can reach the port. A
bodiless POST is a CORS "simple request", sent with no preflight, and
CORS withholds only the reply — so it never prevents the act. Every
state-changing method therefore checks Origin, and a foreign one is a
403. Requests with no Origin at all are allowed, because curl, run.sh
and the test suite send none and only a browser can be made to forge one.
CLAUDE.md and design.html carry the full design, the decisions behind
it, and what a real 2,200-book library taught us — including several
defects in the rendering engine, and a few of our own, that no synthetic
fixture would have produced.
All of it is open source, and all of it is free — as in speech and as in beer. There is nothing to buy, no account to open, no tier to upgrade, no telemetry and no service that can be withdrawn. It runs on a machine you own, against files you already have.
| foliate-js — MIT | Parsing, pagination, tables of contents, in-book search, and CFI generation and resolution: the genuinely hard part of annotating a book. Vendored as a pinned submodule and never edited — differences live in static/reader.js, the only file that imports it. |
| zip.js — BSD-3 | Reads the zip an EPUB is. Vendored by foliate-js. |
| fflate — MIT | Inflate, small and fast. Vendored by foliate-js. |
| PDF.js — Apache 2.0 | Draws PDF pages onto a canvas. Vendored by foliate-js. |
| FastAPI — MIT | The routes, the request models and the validation. |
| hypercorn — MIT | The ASGI server, and the TLS. |
| pydantic — MIT | Request bodies that refuse what they should refuse. |
| SQLite — public domain | The whole database, including FTS5 for search. It ships inside Python; there is no server to run. |
| Pillow — MIT-CMU | Cover thumbnails. |
| pypdfium2 — BSD-3 / Apache 2.0 | Renders page one of a PDF when that page is the cover. Ships as a wheel with the PDFium binary in it — no build step, no network. |
libarchive — BSD-2, as bsdtar |
Reads comics packed with RAR. It ships with macOS; nothing is installed. |
| python-dotenv — BSD-3 | Reads the one .env that configures everything. |
| Open Library — Internet Archive, free API, CC0 data | Authors, bibliographies, covers and the works lists behind Also published. Their limits are published and respected: one request a second anonymously, three for a client that identifies itself, and every answer cached so nothing is ever asked twice. They ask that bulk work use their monthly data dumps instead, which is fair. |
| BISAC subject headings — Book Industry Study Group | The ~40-term genre vocabulary in data/genres.tsv. |
| Google Books — free tier, optional | Broader synopses and categories. Unauthenticated requests are rate-limited to nothing useful, so it is off unless you set a key. |
pytest (MIT), Playwright (Apache 2.0), ruff (MIT), coverage.py (Apache 2.0) — and Python itself (PSF).
Thank you to all of them. This is a few thousand lines of glue on top of a great deal of somebody else's careful work.
MIT — see LICENSE. Do what you like with it.